Compare commits

...
Author SHA1 Message Date
Abdul WahabandClaude Fable 5 281f30e5cc Fix: skipScan must cover the visual contrast stage too
Bugbot on PR #665: the skipScan guard emptied only the analytic
collectBrowserFindings pass, and scan()'s detached visual-contrast
stage then repopulated an ignoreFiles-waived page with contrast
markers and a second non-zero results post. Hoist the guard into
skipScanActive() and honor it in scan() and the async collector;
regenerate the browser bundle.

Adds a browser-backed regression test that reproduces the leak
(second results post carrying low-contrast findings) and pins the
zero contract; drops a tautological assert flagged in review.

AI-assisted change: implemented with Claude Code under maintainer
direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 12:20:02 +05:00
Abdul WahabandClaude Fable 5 f031eb0b9f Fix: write the build-path flip before answering the POST
serve-question answered POST /build-path with 200 and only then wrote the
flip file. The caller is a separate process, so the response could reach it
while the server was still preempted before the write landed: a poller that
trusted the 200 could look for the flip file and miss it.

Measured on a loaded machine, the old order lost that race 29 times out of
40; writing first and answering after loses it 0 times out of 40. This is
what made tests/serve-question.test.mjs fail intermittently in CI on the
Node 22 job while passing on Node 24.

AI-assisted change: diagnosed and implemented with Claude Code under
maintainer direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 09:38:40 +05:00
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
github-actions[bot] 31dcc687c6 Sync generated provider output 2026-08-28 02:14:29 +00:00
45943c3b1f Fix: assert only the common ancestor of the glob roots as a URL prefix
Cursor's review caught the previous commit over-correcting. One tree listed
at two depths (prototype/*.html plus prototype/library/**/*.html) derived
two roots, and requiring a waiver to match under both stopped a normal
project-relative waiver like prototype/index.html from applying anywhere.

The rule both reviews were circling is simpler: one live session is served
by one server, so a single document root must sit at or above every
configured page. The only prefix the resolver can safely assert is the
deepest common ancestor of the glob roots. Nested roots collapse to their
shared tree, so normal waivers keep applying. Disjoint roots (src/ and
public/) share nothing, so no prefix is asserted and only the URL path
itself matches, which keeps the earlier fix intact: a src/foo.html waiver
still cannot hide a finding on a page served from public/foo.html.

This also deletes the match-under-every-root machinery from the previous
commit; with a single asserted prefix, plain matching is enough.

Also switches the new test file to derive the repo root from
import.meta.url rather than process.cwd(), per review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 07:13:43 +05:00
ce1c9f8dad Fix: a waiver scoped to one served root must not hide findings on another
Greptile's review found a real bug in the new resolver. When the live config
lists pages under more than one folder (src/**/*.html and public/**/*.html),
the overlay treated a URL like /foo.html as src/foo.html and public/foo.html
at the same time. A waiver written only for src/foo.html could then hide a
finding on the page actually served from public/foo.html. That fails in the
worst direction: a real finding disappears and nothing says so.

The overlay can never look up the right file. The live server does not serve
the pages; the project's own dev or static server does, and its URL-to-file
mapping is invisible from here. So the fix stops guessing: a file-scoped
waiver now applies only when it matches the URL path itself, which is true
whichever folder serves the page, or when it matches under every configured
folder, so no possible reading disagrees. Anything ambiguous shows the
finding, which is also what the CLI reports for the file really being served.

With a single configured root, the common case, nothing changes: the new
rule reduces to the old behaviour exactly. Multi-root projects keep three
ways to write a waiver that still applies: name the file under each folder,
use the bare path, or use **/.

Two new unit tests pin the ambiguous case and the safe spellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 07:13:43 +05:00
5330fa358e Fix: honour .impeccable detector ignores in the live overlay (#639)
The live overlay's detect scan ran unfiltered: requestDetectScan() posted
only { scanId }, so detector.ignoreRules and detector.ignoreValues in
.impeccable/config.json reached impeccable detect and the edit hook but
never the surface a designer actually watches.

The server now serializes the project's detector waivers into the /live.js
prelude (window.__IMPECCABLE_PROJECT_IGNORES__), read per request through
hook-lib's readConfig so config.local.json wins and edits land on the next
tab reload. A new script part, live-browser-ignores.js, resolves that
config against the page URL when a scan starts: ignoreRules suppress
outright, wildcard ignoreValues suppress their rule in the files their
globs name, and the remaining entries ride along as disabledValues for the
detector to match on each finding's own value. The detector bundle applies
those where the findings are assembled, since the overlay draws its own
markers from the collected findings.

Scope resolution mirrors cli/lib/impeccable-config.mjs deliberately: the
same glob dialect (globToRegex, including {a,b} alternation), the same
path-suffix matching as findingMatchesScopedIgnoreFile, and the same
refusal to apply an unscoped wildcard entry. The served-root prefixes that
bridge project-relative globs and site-relative URLs come from the inject
config's own files globs, never from the ignore globs; deriving them from
the ignore globs lets one entry scoped to prototype/library/** lend its
prefix to every page and suppress site-wide, which looks like success
because the numbers go down.

Known gaps, recorded in the detector comment: the motion value extractor
is not mirrored, so a value-scoped bounce-easing waiver only matches when
the finding carries ignoreValue directly, and design-system-color matches
on the normalized string without the CLI's color-equality fallback.

Tests: unit tests for the resolver part (stale globals, string ignoreRules,
malformed entries, directory URLs, percent-escapes, glob metacharacters,
the roots trap), an extension-mode puppeteer test that disabledValues
suppress exactly the waived findings, and the live-browser regression pin
now asserts the new scan config shape instead of { scanId }.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 07:13:43 +05:00
github-actions[bot] f86473ba7d Sync generated provider output 2026-08-28 00:53:54 +00:00
377fb112b0 Pass the session key from detached idle-grace tests
Main's #469 tests POSTed /heartbeat and /answer without ?key=, which the
gate now rejects, so those daemons looked dead. The e2e heartbeat counter
also has to match pathname rather than a suffix, now that the URL carries
the key.

Written with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:25 +05:00
7982002dac Allow bare loopback Host/Origin on port 80, where browsers omit the suffix
Bugbot caught that the exact-match allowlists 403 every request on --port 80
because browsers drop the default-port suffix; other ports stay strict.

Written with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:25 +05:00
2e075dc58c Gate the build-path flip behind the same session key and origin checks
An unauthenticated POST /build-path wrote the flip event that makes --wait
instruct the agent to generate comps: same class as the /answer hole in #555.

Written with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:25 +05:00
eaaecbd1fe Fix: require session key and origin/host checks on serve-question POSTs (#555)
Unauthenticated POST /answer copied steer into the agent ANSWER line. The handler now requires the detached session key and rejects foreign Origin and Host.

Written with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:25 +05:00
d690349db1 Fix: keep URL basic-auth credentials on the scan origin (#657)
page.authenticate is page-wide, so a cross-origin redirect that then 401s would receive the original credentials. Attach Authorization only to requests for the scan origin.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:01 +05:00
d5873ff8eb Fix: redact URL userinfo from detect findings (#657)
Strip basic-auth credentials from scan-target URLs before goto and finding output, and pass them to page.authenticate instead.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:01 +05:00
github-actions[bot] 1df992ade0 Sync generated provider output 2026-08-28 00:44:04 +00:00
be87f5eb86 Fix: refuse inert exact ignore-value entries (#662)
ignore-value stored exact values for rules that cannot extract one, so the entries never matched. Refuse them and point at "*" --file.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:43:36 +05:00
af2e8b3ac3 Fix: stream bundle downloads to disk instead of buffering
AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:43:04 +05:00
5d932f9fbe Fix: safe temp staging and downloadFile error handling (#479)
AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:43:04 +05:00
168 changed files with 10422 additions and 309 deletions
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
+15 -1
View File
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8334,6 +8334,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -0,0 +1,201 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// 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) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.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);
}
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];
}
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}> }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots);
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,24 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// 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.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
const ignores = typeof ignoresApi?.resolveDetectIgnores === 'function'
? ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
})
: { disabledRules: [], disabledValues: [] };
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: { scanId, disabledRules: ignores.disabledRules, disabledValues: ignores.disabledValues },
}, '*');
}
@@ -22,6 +22,7 @@ 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,
@@ -45,6 +46,7 @@ import {
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
resolveLiveConfigPath,
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
@@ -694,6 +696,51 @@ 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}`);
@@ -754,6 +801,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
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(),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,7 +1662,8 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1648,7 +1683,8 @@ const server = http.createServer((req, res) => {
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
@@ -1675,6 +1675,69 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// 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).
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
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);
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),

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