Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-08-28 02:14:29 +00:00
parent 45943c3b1f
commit 31dcc687c6
96 changed files with 6400 additions and 32 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),
@@ -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 || '');