Compare commits

..
Author SHA1 Message Date
Paul Bakaus 5712d78255 Isolate explicit targets at Git boundaries
Keep nested repositories and external targets out of caller and home-level context or hook discovery.

AI assistance disclosure: Codex helped implement and test this fix under maintainer direction.
2026-09-02 11:00:33 -07:00
Paul Bakaus f54f7ce3e5 Resolve external targets from their own repository
Scope explicit sibling targets to their own Git root so caller context and hook manifests cannot suppress required detector guidance.

AI assistance disclosure: This commit was prepared with Codex under maintainer direction.
2026-09-02 10:07:41 -07:00
Paul Bakaus 61092e1c58 Detect proxy CSP in nested Next apps
Recognize proxy files at root or src placement relative to nested Next project markers while continuing to ignore unrelated proxy helpers.

AI assistance disclosure: This commit was prepared with Codex under maintainer direction.
2026-09-02 09:54:41 -07:00
Paul Bakaus f9d28b57bd Keep hook discovery within target repository
Stop manifest discovery at the target repository boundary instead of re-adding an outer workspace root, with regression coverage for nested Git targets.

AI assistance disclosure: This commit was prepared with Codex under maintainer direction.
2026-09-02 09:53:11 -07:00
Paul Bakaus c2404197e4 Honor ancestor hook disable config
AI assistance disclosure: Codex implemented and verified this fix under maintainer direction.
2026-09-02 09:37:19 -07:00
Paul Bakaus 0e1f94acae Tighten hook and proxy discovery
AI assistance disclosure: Codex implemented and verified these fixes under maintainer direction.
2026-09-02 09:35:01 -07:00
Paul Bakaus be43532192 Fix CSP and hook ancestor discovery
Recognize Next.js 16 proxy files when detecting runtime CSP and mirror harness ancestor lookup when locating active hook manifests for nested projects.

AI assistance disclosure: Implemented and verified with Codex under maintainer direction.
2026-09-02 09:13:53 -07:00
1911 changed files with 110830 additions and 186811 deletions
+10 -104
View File
@@ -200,20 +200,7 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
if (!repoRoot && targetGitRoot) {
const cwdGitRoot = findGitBoundaryRoot(absCwd);
if (targetGitRoot !== cwdGitRoot) {
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
repoRoot: targetGitRoot,
isMonorepo: false,
};
}
}
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
const targetIsExternal = hasTargetOption(options)
&& targetDir !== absCwd
&& !isPathInside(targetDir, absCwd);
if (targetIsExternal) {
const targetRepoRoot = targetGitRoot || targetDir;
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
repoRoot: targetRepoRoot,
isMonorepo: false,
};
}
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -248,18 +223,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
function findGitBoundaryRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
while (true) {
if (dir === homeDir) return null;
if (hasGitBoundary(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
function resolveTargetPath(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
if (fs.existsSync(abs)) return abs;
return findUniqueBareTarget(cwd, targetPath) || abs;
}
function findUniqueBareTarget(cwd, targetPath) {
const absCwd = path.resolve(cwd);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
const rel = path.relative(absCwd, abs);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
const segments = rel.split(path.sep).filter(Boolean);
if (segments.length !== 1) return null;
const name = segments[0];
const repoRoot = findMonorepoRoot(absCwd);
if (!repoRoot) return null;
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
if (matches.length !== 1) return null;
return path.resolve(repoRoot, matches[0].path);
}
function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try {
const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err;
}
const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0);
}
const ctx = loadContext(
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const ctx = loadContext(process.cwd(), cliOptions);
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}
function pathExistsForTarget(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
return fs.existsSync(abs);
}
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
// Harness project settings are discovered by walking up from the resolved
// project root. Its hook manifest can live at an enclosing git root, so
// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED
// directive. Starting from projectRoot also prevents an explicit target from
// borrowing an unrelated manifest near the caller. The walk itself is the
// authority: do not append repoRoot afterward, because resolveProject can
// retain an outer workspace root for a target inside an independent nested
// Git repository.
function hookManifestSearchRoots(ctx) {
const roots = [];
const seen = new Set();
const add = (root) => {
if (!root) return;
const resolved = path.resolve(root);
if (seen.has(resolved)) return;
seen.add(resolved);
roots.push(resolved);
};
let current = path.resolve(ctx.projectRoot || process.cwd());
const home = path.resolve(os.homedir());
while (true) {
if (current === home) break;
add(current);
if (hasGitBoundary(current)) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return roots;
}
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) {
// A manifest can live above the resolved product. Honor the hook lifecycle
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
for (const root of roots) {
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
* but not auto-patched in v1.
* - "middleware": CSP set dynamically in middleware.{ts,js}.
* Detected but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
const NEXT_MIDDLEWARE_FILES = new Set([
'middleware.ts',
'middleware.js',
'middleware.mjs',
]);
const NEXT_PROXY_FILES = new Set([
'proxy.ts',
'proxy.js',
'proxy.mjs',
]);
const NEXT_CONFIG_FILES = [
'next.config.js',
'next.config.mjs',
'next.config.cjs',
'next.config.ts',
'next.config.mts',
'next.config.cts',
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
function hasNextProjectMarker(projectRoot) {
if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
return ['dependencies', 'devDependencies', 'peerDependencies']
.some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
} catch {
return false;
}
}
function isNextRequestHookFile(root, absPath, relPath, base) {
if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
if (!NEXT_PROXY_FILES.has(base)) return false;
const normalized = relPath.split(path.sep).join('/').toLowerCase();
// Next.js 16 recognizes proxy at the project root or in the optional src/
// directory, alongside app/ or pages/. The scan root is commonly a
// monorepo, so also accept that placement relative to a nested directory
// that carries a concrete Next.js project marker. A same-named helper
// elsewhere in the tree is not the framework request hook.
if (normalized === base || normalized === `src/${base}`) return true;
const hookDir = path.dirname(absPath);
const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
? path.dirname(hookDir)
: hookDir;
if (path.resolve(projectRoot) === path.resolve(root)) return true;
return hasNextProjectMarker(projectRoot);
}
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -2028,16 +1650,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -37,30 +37,13 @@ function fileUrlToLocalPath(url) {
}
}
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
// Some agent runners hand a shell-ready URL list to Node as one argv value.
// A browser accepts the spaces as part of one encoded URL, producing a
// plausible scan attributed to a bogus joined path. Expand only when every
// whitespace-delimited token is independently a URL, preserving ordinary
// filesystem paths that contain spaces.
function expandJoinedUrlTargets(targets) {
return targets.flatMap((target) => {
if (!/\s/.test(target)) return [target];
const parts = target.trim().split(/\s+/).filter(Boolean);
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
? parts
: [target];
});
}
// Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
// filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs).
function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
return finding && finding.advisory === true;
}
function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the
failure count so they never block automation. --no-advisory hides them.
Output streams:
Human-readable findings go to stderr so stdout stays available for structured
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
Exit status:
0 Scan completed with no primary findings (advisories may still be listed)
1 At least one requested target could not be scanned
2 Scan completed with primary findings
Operational failure takes precedence when a multi-target scan is partial.
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included)
http(s):// and file:// URLs)
Examples:
impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
};
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
let allFindings = [];
let hadOperationalFailure = false;
const reportLocalScanFailure = (target, error) => {
hadOperationalFailure = true;
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
};
if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
let browserDetector = null;
let browserSetupFailed = false;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
const urlRe = /^(?:https?|file):\/\//i;
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (URL_TARGET_RE.test(target)) {
if (browserSetupFailed) continue;
if (urlRe.test(target)) {
// A file:// URL points at a local artifact, so its design system
// resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target));
} catch (e) {
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
continue;
}
const resolved = path.resolve(target);
let stat;
try { stat = fs.statSync(resolved); }
catch {
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +352,7 @@ async function detectCli() {
}
}
const files = walkDir(resolved, reportLocalScanFailure)
const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +368,7 @@ async function detectCli() {
}
// Build import graph for multi-file awareness
const unreadableFiles = new Set();
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
const graph = buildImportGraph(files);
// Build reverse map: file -> set of files that import it
const importedByMap = new Map();
for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
}
for (const file of files) {
if (unreadableFiles.has(file)) continue;
try {
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
}
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try {
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
}
}
} finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
}
}
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode);
process.exit(primary.length > 0 ? 2 : 0);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode);
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -358,7 +358,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -1961,10 +1961,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -8132,17 +8126,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -8932,16 +8548,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
@@ -394,7 +394,7 @@ async function detectUrl(rawUrl, options = {}) {
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return deriveAdvisoryFlag(item);
return item;
});
}
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]);
}
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
fmt: (m, line) => {
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -9,7 +9,7 @@ import {
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item));
findings.push(item);
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(id);
}
function deriveAdvisoryFlag(item) {
if (item.severity === 'advisory') item.advisory = true;
else delete item.advisory;
return item;
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
@@ -17,7 +11,8 @@ function finding(id, filePath, snippet, line = 0) {
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
// can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules.
return deriveAdvisoryFlag(base);
if (ap.advisory === true) base.advisory = true;
return base;
}
export { getAP, finding, deriveAdvisoryFlag };
export { getAP, finding };
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir, onReadError = null) {
function walkDir(dir) {
const files = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null;
}
function buildImportGraph(files, onReadError = null) {
function buildImportGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const file of files) {
let content;
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const content = fs.readFileSync(file, 'utf-8');
const dir = path.dirname(file);
const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -588,10 +588,9 @@ function getAntipattern(id) {
// Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect
// exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer
// derives its `advisory: true` compatibility/output flag from this set.
// The set is derived from the registry so a rule only needs `advisory: true`.
const ADVISORY_RULE_IDS = new Set(
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').map(rule => rule.id),
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
);
function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -688,10 +688,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -138,20 +138,17 @@ export const IMMEDIATE_TIER_RULES = new Set([
// the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they
// carry neither the current runtime flag nor the canonical advisory severity.
// Current findings are classified by their serialized metadata below.
// This set is the hook's own copy of the registry's `advisory: true` rules,
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// self-contained and testable without loading the detector. Keep it in sync
// with the registry (cli/engine/registry/antipatterns.mjs).
export const ADVISORY_RULES = new Set([
'em-dash-overuse',
]);
export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && (
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
}
export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor;
}
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
}
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl
|| null;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
}
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num);
return true;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return;
}
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
}
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
}
rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId);
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return;
@@ -6392,7 +6326,14 @@
return;
}
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts);
const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
}
return;
}
@@ -6434,7 +6375,7 @@
return;
}
const existingWrapper = findVariantsWrapper(sessionId);
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove();
}
/**
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
removeDiscardStateStylesheet(sessionId);
if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove();
}
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal
//
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
}
if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') {
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
disableInlineEdit();
refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null;
awaitingAcceptResult = null;
setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
});
}
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (!shaderState) return;
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
shaderState = null;
removeStrayShaderNode();
}
function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay();
if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob);
return;
}
@@ -8640,22 +8488,16 @@ void main() {
}
// Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap;
try {
bitmap = await createImageBitmap(blob);
} catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob);
return;
}
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() {
if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(),
};
if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId);
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues };
}
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
});
@@ -8806,7 +8646,7 @@ void main() {
}
function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null;
return {
@@ -8933,7 +8773,7 @@ void main() {
}
function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
}
function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper();
const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one
// wrapper per item, and hiding only one leaves the rest of the
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (wrapper) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
else wrapper.style.display = 'none';
}
setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet();
return;
}
const lateWrappers = discardedWrappers(cleanupSessionId);
if (lateWrappers.length === 0) {
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId);
return;
}
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}
return;
}
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race.
setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId);
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return;
}
removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
if (staleWrapper) location.reload();
}, 2000);
return;
}
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000);
}
hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
}
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
// Which path resumed matters in the journal: an init resume is a fresh
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
function resumeSession(recoveryRevision = liveInteractionRevision) {
const wrapper = document.querySelector('[data-impeccable-variants]');
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's
// generation preflight runs live-wrap with --defer-source-write, so the
// wrapper and every variant reach the DOM in one HMR batch, and the
// deferred-wrapper scout (constructed at init) runs before the variant
// MutationObserver (constructed at Go) on that batch. Finish the same
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
if (state === 'CYCLING') refreshParamsPanel();
saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress');
} else {
queueCheckpoint(resumeReason);
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
queueCheckpoint('browser_resumed');
}
// Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return;
scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
}
});
-2
View File
@@ -1,2 +0,0 @@
[alias]
xtask = "run --quiet --package xtask --"
+10 -104
View File
@@ -200,20 +200,7 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
if (!repoRoot && targetGitRoot) {
const cwdGitRoot = findGitBoundaryRoot(absCwd);
if (targetGitRoot !== cwdGitRoot) {
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
repoRoot: targetGitRoot,
isMonorepo: false,
};
}
}
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
const targetIsExternal = hasTargetOption(options)
&& targetDir !== absCwd
&& !isPathInside(targetDir, absCwd);
if (targetIsExternal) {
const targetRepoRoot = targetGitRoot || targetDir;
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
repoRoot: targetRepoRoot,
isMonorepo: false,
};
}
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -248,18 +223,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
function findGitBoundaryRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
while (true) {
if (dir === homeDir) return null;
if (hasGitBoundary(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
function resolveTargetPath(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
if (fs.existsSync(abs)) return abs;
return findUniqueBareTarget(cwd, targetPath) || abs;
}
function findUniqueBareTarget(cwd, targetPath) {
const absCwd = path.resolve(cwd);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
const rel = path.relative(absCwd, abs);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
const segments = rel.split(path.sep).filter(Boolean);
if (segments.length !== 1) return null;
const name = segments[0];
const repoRoot = findMonorepoRoot(absCwd);
if (!repoRoot) return null;
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
if (matches.length !== 1) return null;
return path.resolve(repoRoot, matches[0].path);
}
function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try {
const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err;
}
const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0);
}
const ctx = loadContext(
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const ctx = loadContext(process.cwd(), cliOptions);
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}
function pathExistsForTarget(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
return fs.existsSync(abs);
}
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
// Harness project settings are discovered by walking up from the resolved
// project root. Its hook manifest can live at an enclosing git root, so
// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED
// directive. Starting from projectRoot also prevents an explicit target from
// borrowing an unrelated manifest near the caller. The walk itself is the
// authority: do not append repoRoot afterward, because resolveProject can
// retain an outer workspace root for a target inside an independent nested
// Git repository.
function hookManifestSearchRoots(ctx) {
const roots = [];
const seen = new Set();
const add = (root) => {
if (!root) return;
const resolved = path.resolve(root);
if (seen.has(resolved)) return;
seen.add(resolved);
roots.push(resolved);
};
let current = path.resolve(ctx.projectRoot || process.cwd());
const home = path.resolve(os.homedir());
while (true) {
if (current === home) break;
add(current);
if (hasGitBoundary(current)) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return roots;
}
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) {
// A manifest can live above the resolved product. Honor the hook lifecycle
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
for (const root of roots) {
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
* but not auto-patched in v1.
* - "middleware": CSP set dynamically in middleware.{ts,js}.
* Detected but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
const NEXT_MIDDLEWARE_FILES = new Set([
'middleware.ts',
'middleware.js',
'middleware.mjs',
]);
const NEXT_PROXY_FILES = new Set([
'proxy.ts',
'proxy.js',
'proxy.mjs',
]);
const NEXT_CONFIG_FILES = [
'next.config.js',
'next.config.mjs',
'next.config.cjs',
'next.config.ts',
'next.config.mts',
'next.config.cts',
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
function hasNextProjectMarker(projectRoot) {
if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
return ['dependencies', 'devDependencies', 'peerDependencies']
.some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
} catch {
return false;
}
}
function isNextRequestHookFile(root, absPath, relPath, base) {
if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
if (!NEXT_PROXY_FILES.has(base)) return false;
const normalized = relPath.split(path.sep).join('/').toLowerCase();
// Next.js 16 recognizes proxy at the project root or in the optional src/
// directory, alongside app/ or pages/. The scan root is commonly a
// monorepo, so also accept that placement relative to a nested directory
// that carries a concrete Next.js project marker. A same-named helper
// elsewhere in the tree is not the framework request hook.
if (normalized === base || normalized === `src/${base}`) return true;
const hookDir = path.dirname(absPath);
const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
? path.dirname(hookDir)
: hookDir;
if (path.resolve(projectRoot) === path.resolve(root)) return true;
return hasNextProjectMarker(projectRoot);
}
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -2028,16 +1650,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -37,30 +37,13 @@ function fileUrlToLocalPath(url) {
}
}
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
// Some agent runners hand a shell-ready URL list to Node as one argv value.
// A browser accepts the spaces as part of one encoded URL, producing a
// plausible scan attributed to a bogus joined path. Expand only when every
// whitespace-delimited token is independently a URL, preserving ordinary
// filesystem paths that contain spaces.
function expandJoinedUrlTargets(targets) {
return targets.flatMap((target) => {
if (!/\s/.test(target)) return [target];
const parts = target.trim().split(/\s+/).filter(Boolean);
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
? parts
: [target];
});
}
// Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
// filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs).
function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
return finding && finding.advisory === true;
}
function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the
failure count so they never block automation. --no-advisory hides them.
Output streams:
Human-readable findings go to stderr so stdout stays available for structured
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
Exit status:
0 Scan completed with no primary findings (advisories may still be listed)
1 At least one requested target could not be scanned
2 Scan completed with primary findings
Operational failure takes precedence when a multi-target scan is partial.
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included)
http(s):// and file:// URLs)
Examples:
impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
};
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
let allFindings = [];
let hadOperationalFailure = false;
const reportLocalScanFailure = (target, error) => {
hadOperationalFailure = true;
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
};
if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
let browserDetector = null;
let browserSetupFailed = false;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
const urlRe = /^(?:https?|file):\/\//i;
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (URL_TARGET_RE.test(target)) {
if (browserSetupFailed) continue;
if (urlRe.test(target)) {
// A file:// URL points at a local artifact, so its design system
// resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target));
} catch (e) {
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
continue;
}
const resolved = path.resolve(target);
let stat;
try { stat = fs.statSync(resolved); }
catch {
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +352,7 @@ async function detectCli() {
}
}
const files = walkDir(resolved, reportLocalScanFailure)
const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +368,7 @@ async function detectCli() {
}
// Build import graph for multi-file awareness
const unreadableFiles = new Set();
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
const graph = buildImportGraph(files);
// Build reverse map: file -> set of files that import it
const importedByMap = new Map();
for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
}
for (const file of files) {
if (unreadableFiles.has(file)) continue;
try {
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
}
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try {
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
}
}
} finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
}
}
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode);
process.exit(primary.length > 0 ? 2 : 0);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode);
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -358,7 +358,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -1961,10 +1961,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -8132,17 +8126,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -8932,16 +8548,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
@@ -394,7 +394,7 @@ async function detectUrl(rawUrl, options = {}) {
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return deriveAdvisoryFlag(item);
return item;
});
}
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]);
}
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
fmt: (m, line) => {
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -9,7 +9,7 @@ import {
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item));
findings.push(item);
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(id);
}
function deriveAdvisoryFlag(item) {
if (item.severity === 'advisory') item.advisory = true;
else delete item.advisory;
return item;
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
@@ -17,7 +11,8 @@ function finding(id, filePath, snippet, line = 0) {
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
// can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules.
return deriveAdvisoryFlag(base);
if (ap.advisory === true) base.advisory = true;
return base;
}
export { getAP, finding, deriveAdvisoryFlag };
export { getAP, finding };
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir, onReadError = null) {
function walkDir(dir) {
const files = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null;
}
function buildImportGraph(files, onReadError = null) {
function buildImportGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const file of files) {
let content;
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const content = fs.readFileSync(file, 'utf-8');
const dir = path.dirname(file);
const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -588,10 +588,9 @@ function getAntipattern(id) {
// Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect
// exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer
// derives its `advisory: true` compatibility/output flag from this set.
// The set is derived from the registry so a rule only needs `advisory: true`.
const ADVISORY_RULE_IDS = new Set(
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').map(rule => rule.id),
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
);
function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -688,10 +688,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -138,20 +138,17 @@ export const IMMEDIATE_TIER_RULES = new Set([
// the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they
// carry neither the current runtime flag nor the canonical advisory severity.
// Current findings are classified by their serialized metadata below.
// This set is the hook's own copy of the registry's `advisory: true` rules,
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// self-contained and testable without loading the detector. Keep it in sync
// with the registry (cli/engine/registry/antipatterns.mjs).
export const ADVISORY_RULES = new Set([
'em-dash-overuse',
]);
export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && (
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
}
export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor;
}
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
}
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl
|| null;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
}
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num);
return true;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return;
}
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
}
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
}
rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId);
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return;
@@ -6392,7 +6326,14 @@
return;
}
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts);
const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
}
return;
}
@@ -6434,7 +6375,7 @@
return;
}
const existingWrapper = findVariantsWrapper(sessionId);
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove();
}
/**
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
removeDiscardStateStylesheet(sessionId);
if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove();
}
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal
//
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
}
if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') {
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
disableInlineEdit();
refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null;
awaitingAcceptResult = null;
setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
});
}
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (!shaderState) return;
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
shaderState = null;
removeStrayShaderNode();
}
function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay();
if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob);
return;
}
@@ -8640,22 +8488,16 @@ void main() {
}
// Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap;
try {
bitmap = await createImageBitmap(blob);
} catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob);
return;
}
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() {
if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(),
};
if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId);
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues };
}
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
});
@@ -8806,7 +8646,7 @@ void main() {
}
function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null;
return {
@@ -8933,7 +8773,7 @@ void main() {
}
function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
}
function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper();
const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one
// wrapper per item, and hiding only one leaves the rest of the
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (wrapper) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
else wrapper.style.display = 'none';
}
setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet();
return;
}
const lateWrappers = discardedWrappers(cleanupSessionId);
if (lateWrappers.length === 0) {
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId);
return;
}
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}
return;
}
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race.
setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId);
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return;
}
removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
if (staleWrapper) location.reload();
}, 2000);
return;
}
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000);
}
hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
}
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
// Which path resumed matters in the journal: an init resume is a fresh
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
function resumeSession(recoveryRevision = liveInteractionRevision) {
const wrapper = document.querySelector('[data-impeccable-variants]');
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's
// generation preflight runs live-wrap with --defer-source-write, so the
// wrapper and every variant reach the DOM in one HMR batch, and the
// deferred-wrapper scout (constructed at init) runs before the variant
// MutationObserver (constructed at Go) on that batch. Finish the same
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
if (state === 'CYCLING') refreshParamsPanel();
saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress');
} else {
queueCheckpoint(resumeReason);
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
queueCheckpoint('browser_resumed');
}
// Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return;
scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
}
});
+10 -104
View File
@@ -200,20 +200,7 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
if (!repoRoot && targetGitRoot) {
const cwdGitRoot = findGitBoundaryRoot(absCwd);
if (targetGitRoot !== cwdGitRoot) {
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
repoRoot: targetGitRoot,
isMonorepo: false,
};
}
}
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
const targetIsExternal = hasTargetOption(options)
&& targetDir !== absCwd
&& !isPathInside(targetDir, absCwd);
if (targetIsExternal) {
const targetRepoRoot = targetGitRoot || targetDir;
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
repoRoot: targetRepoRoot,
isMonorepo: false,
};
}
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -248,18 +223,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
function findGitBoundaryRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
while (true) {
if (dir === homeDir) return null;
if (hasGitBoundary(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
function resolveTargetPath(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
if (fs.existsSync(abs)) return abs;
return findUniqueBareTarget(cwd, targetPath) || abs;
}
function findUniqueBareTarget(cwd, targetPath) {
const absCwd = path.resolve(cwd);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
const rel = path.relative(absCwd, abs);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
const segments = rel.split(path.sep).filter(Boolean);
if (segments.length !== 1) return null;
const name = segments[0];
const repoRoot = findMonorepoRoot(absCwd);
if (!repoRoot) return null;
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
if (matches.length !== 1) return null;
return path.resolve(repoRoot, matches[0].path);
}
function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try {
const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err;
}
const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0);
}
const ctx = loadContext(
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const ctx = loadContext(process.cwd(), cliOptions);
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}
function pathExistsForTarget(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
return fs.existsSync(abs);
}
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
// Harness project settings are discovered by walking up from the resolved
// project root. Its hook manifest can live at an enclosing git root, so
// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED
// directive. Starting from projectRoot also prevents an explicit target from
// borrowing an unrelated manifest near the caller. The walk itself is the
// authority: do not append repoRoot afterward, because resolveProject can
// retain an outer workspace root for a target inside an independent nested
// Git repository.
function hookManifestSearchRoots(ctx) {
const roots = [];
const seen = new Set();
const add = (root) => {
if (!root) return;
const resolved = path.resolve(root);
if (seen.has(resolved)) return;
seen.add(resolved);
roots.push(resolved);
};
let current = path.resolve(ctx.projectRoot || process.cwd());
const home = path.resolve(os.homedir());
while (true) {
if (current === home) break;
add(current);
if (hasGitBoundary(current)) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return roots;
}
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) {
// A manifest can live above the resolved product. Honor the hook lifecycle
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
for (const root of roots) {
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
* but not auto-patched in v1.
* - "middleware": CSP set dynamically in middleware.{ts,js}.
* Detected but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
const NEXT_MIDDLEWARE_FILES = new Set([
'middleware.ts',
'middleware.js',
'middleware.mjs',
]);
const NEXT_PROXY_FILES = new Set([
'proxy.ts',
'proxy.js',
'proxy.mjs',
]);
const NEXT_CONFIG_FILES = [
'next.config.js',
'next.config.mjs',
'next.config.cjs',
'next.config.ts',
'next.config.mts',
'next.config.cts',
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
function hasNextProjectMarker(projectRoot) {
if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
return ['dependencies', 'devDependencies', 'peerDependencies']
.some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
} catch {
return false;
}
}
function isNextRequestHookFile(root, absPath, relPath, base) {
if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
if (!NEXT_PROXY_FILES.has(base)) return false;
const normalized = relPath.split(path.sep).join('/').toLowerCase();
// Next.js 16 recognizes proxy at the project root or in the optional src/
// directory, alongside app/ or pages/. The scan root is commonly a
// monorepo, so also accept that placement relative to a nested directory
// that carries a concrete Next.js project marker. A same-named helper
// elsewhere in the tree is not the framework request hook.
if (normalized === base || normalized === `src/${base}`) return true;
const hookDir = path.dirname(absPath);
const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
? path.dirname(hookDir)
: hookDir;
if (path.resolve(projectRoot) === path.resolve(root)) return true;
return hasNextProjectMarker(projectRoot);
}
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -2028,16 +1650,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -37,30 +37,13 @@ function fileUrlToLocalPath(url) {
}
}
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
// Some agent runners hand a shell-ready URL list to Node as one argv value.
// A browser accepts the spaces as part of one encoded URL, producing a
// plausible scan attributed to a bogus joined path. Expand only when every
// whitespace-delimited token is independently a URL, preserving ordinary
// filesystem paths that contain spaces.
function expandJoinedUrlTargets(targets) {
return targets.flatMap((target) => {
if (!/\s/.test(target)) return [target];
const parts = target.trim().split(/\s+/).filter(Boolean);
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
? parts
: [target];
});
}
// Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
// filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs).
function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
return finding && finding.advisory === true;
}
function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the
failure count so they never block automation. --no-advisory hides them.
Output streams:
Human-readable findings go to stderr so stdout stays available for structured
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
Exit status:
0 Scan completed with no primary findings (advisories may still be listed)
1 At least one requested target could not be scanned
2 Scan completed with primary findings
Operational failure takes precedence when a multi-target scan is partial.
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included)
http(s):// and file:// URLs)
Examples:
impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
};
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
let allFindings = [];
let hadOperationalFailure = false;
const reportLocalScanFailure = (target, error) => {
hadOperationalFailure = true;
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
};
if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
let browserDetector = null;
let browserSetupFailed = false;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
const urlRe = /^(?:https?|file):\/\//i;
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (URL_TARGET_RE.test(target)) {
if (browserSetupFailed) continue;
if (urlRe.test(target)) {
// A file:// URL points at a local artifact, so its design system
// resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target));
} catch (e) {
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
continue;
}
const resolved = path.resolve(target);
let stat;
try { stat = fs.statSync(resolved); }
catch {
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +352,7 @@ async function detectCli() {
}
}
const files = walkDir(resolved, reportLocalScanFailure)
const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +368,7 @@ async function detectCli() {
}
// Build import graph for multi-file awareness
const unreadableFiles = new Set();
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
const graph = buildImportGraph(files);
// Build reverse map: file -> set of files that import it
const importedByMap = new Map();
for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
}
for (const file of files) {
if (unreadableFiles.has(file)) continue;
try {
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
}
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try {
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
}
}
} finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
}
}
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode);
process.exit(primary.length > 0 ? 2 : 0);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode);
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -358,7 +358,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -1961,10 +1961,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -8132,17 +8126,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -8932,16 +8548,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
@@ -394,7 +394,7 @@ async function detectUrl(rawUrl, options = {}) {
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return deriveAdvisoryFlag(item);
return item;
});
}
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]);
}
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
fmt: (m, line) => {
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -9,7 +9,7 @@ import {
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item));
findings.push(item);
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(id);
}
function deriveAdvisoryFlag(item) {
if (item.severity === 'advisory') item.advisory = true;
else delete item.advisory;
return item;
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
@@ -17,7 +11,8 @@ function finding(id, filePath, snippet, line = 0) {
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
// can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules.
return deriveAdvisoryFlag(base);
if (ap.advisory === true) base.advisory = true;
return base;
}
export { getAP, finding, deriveAdvisoryFlag };
export { getAP, finding };
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir, onReadError = null) {
function walkDir(dir) {
const files = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null;
}
function buildImportGraph(files, onReadError = null) {
function buildImportGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const file of files) {
let content;
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const content = fs.readFileSync(file, 'utf-8');
const dir = path.dirname(file);
const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -588,10 +588,9 @@ function getAntipattern(id) {
// Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect
// exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer
// derives its `advisory: true` compatibility/output flag from this set.
// The set is derived from the registry so a rule only needs `advisory: true`.
const ADVISORY_RULE_IDS = new Set(
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').map(rule => rule.id),
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
);
function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -688,10 +688,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -138,20 +138,17 @@ export const IMMEDIATE_TIER_RULES = new Set([
// the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they
// carry neither the current runtime flag nor the canonical advisory severity.
// Current findings are classified by their serialized metadata below.
// This set is the hook's own copy of the registry's `advisory: true` rules,
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// self-contained and testable without loading the detector. Keep it in sync
// with the registry (cli/engine/registry/antipatterns.mjs).
export const ADVISORY_RULES = new Set([
'em-dash-overuse',
]);
export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && (
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
}
export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor;
}
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
}
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl
|| null;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
}
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num);
return true;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return;
}
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
}
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
}
rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId);
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return;
@@ -6392,7 +6326,14 @@
return;
}
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts);
const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
}
return;
}
@@ -6434,7 +6375,7 @@
return;
}
const existingWrapper = findVariantsWrapper(sessionId);
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove();
}
/**
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
removeDiscardStateStylesheet(sessionId);
if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove();
}
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal
//
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
}
if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') {
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
disableInlineEdit();
refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null;
awaitingAcceptResult = null;
setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
});
}
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (!shaderState) return;
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
shaderState = null;
removeStrayShaderNode();
}
function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay();
if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob);
return;
}
@@ -8640,22 +8488,16 @@ void main() {
}
// Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap;
try {
bitmap = await createImageBitmap(blob);
} catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob);
return;
}
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() {
if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(),
};
if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId);
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues };
}
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
});
@@ -8806,7 +8646,7 @@ void main() {
}
function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null;
return {
@@ -8933,7 +8773,7 @@ void main() {
}
function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
}
function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper();
const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one
// wrapper per item, and hiding only one leaves the rest of the
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (wrapper) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
else wrapper.style.display = 'none';
}
setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet();
return;
}
const lateWrappers = discardedWrappers(cleanupSessionId);
if (lateWrappers.length === 0) {
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId);
return;
}
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}
return;
}
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race.
setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId);
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return;
}
removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
if (staleWrapper) location.reload();
}, 2000);
return;
}
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000);
}
hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
}
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
// Which path resumed matters in the journal: an init resume is a fresh
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
function resumeSession(recoveryRevision = liveInteractionRevision) {
const wrapper = document.querySelector('[data-impeccable-variants]');
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's
// generation preflight runs live-wrap with --defer-source-write, so the
// wrapper and every variant reach the DOM in one HMR batch, and the
// deferred-wrapper scout (constructed at init) runs before the variant
// MutationObserver (constructed at Go) on that batch. Finish the same
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
if (state === 'CYCLING') refreshParamsPanel();
saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress');
} else {
queueCheckpoint(resumeReason);
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
queueCheckpoint('browser_resumed');
}
// Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return;
scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
}
});
+10 -104
View File
@@ -200,20 +200,7 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
if (!repoRoot && targetGitRoot) {
const cwdGitRoot = findGitBoundaryRoot(absCwd);
if (targetGitRoot !== cwdGitRoot) {
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
repoRoot: targetGitRoot,
isMonorepo: false,
};
}
}
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
const targetIsExternal = hasTargetOption(options)
&& targetDir !== absCwd
&& !isPathInside(targetDir, absCwd);
if (targetIsExternal) {
const targetRepoRoot = targetGitRoot || targetDir;
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
repoRoot: targetRepoRoot,
isMonorepo: false,
};
}
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -248,18 +223,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
function findGitBoundaryRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
while (true) {
if (dir === homeDir) return null;
if (hasGitBoundary(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
function resolveTargetPath(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
if (fs.existsSync(abs)) return abs;
return findUniqueBareTarget(cwd, targetPath) || abs;
}
function findUniqueBareTarget(cwd, targetPath) {
const absCwd = path.resolve(cwd);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
const rel = path.relative(absCwd, abs);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
const segments = rel.split(path.sep).filter(Boolean);
if (segments.length !== 1) return null;
const name = segments[0];
const repoRoot = findMonorepoRoot(absCwd);
if (!repoRoot) return null;
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
if (matches.length !== 1) return null;
return path.resolve(repoRoot, matches[0].path);
}
function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try {
const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err;
}
const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0);
}
const ctx = loadContext(
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const ctx = loadContext(process.cwd(), cliOptions);
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}
function pathExistsForTarget(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
return fs.existsSync(abs);
}
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
// Harness project settings are discovered by walking up from the resolved
// project root. Its hook manifest can live at an enclosing git root, so
// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED
// directive. Starting from projectRoot also prevents an explicit target from
// borrowing an unrelated manifest near the caller. The walk itself is the
// authority: do not append repoRoot afterward, because resolveProject can
// retain an outer workspace root for a target inside an independent nested
// Git repository.
function hookManifestSearchRoots(ctx) {
const roots = [];
const seen = new Set();
const add = (root) => {
if (!root) return;
const resolved = path.resolve(root);
if (seen.has(resolved)) return;
seen.add(resolved);
roots.push(resolved);
};
let current = path.resolve(ctx.projectRoot || process.cwd());
const home = path.resolve(os.homedir());
while (true) {
if (current === home) break;
add(current);
if (hasGitBoundary(current)) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return roots;
}
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) {
// A manifest can live above the resolved product. Honor the hook lifecycle
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
for (const root of roots) {
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
* but not auto-patched in v1.
* - "middleware": CSP set dynamically in middleware.{ts,js}.
* Detected but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
const NEXT_MIDDLEWARE_FILES = new Set([
'middleware.ts',
'middleware.js',
'middleware.mjs',
]);
const NEXT_PROXY_FILES = new Set([
'proxy.ts',
'proxy.js',
'proxy.mjs',
]);
const NEXT_CONFIG_FILES = [
'next.config.js',
'next.config.mjs',
'next.config.cjs',
'next.config.ts',
'next.config.mts',
'next.config.cts',
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
function hasNextProjectMarker(projectRoot) {
if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
return ['dependencies', 'devDependencies', 'peerDependencies']
.some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
} catch {
return false;
}
}
function isNextRequestHookFile(root, absPath, relPath, base) {
if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
if (!NEXT_PROXY_FILES.has(base)) return false;
const normalized = relPath.split(path.sep).join('/').toLowerCase();
// Next.js 16 recognizes proxy at the project root or in the optional src/
// directory, alongside app/ or pages/. The scan root is commonly a
// monorepo, so also accept that placement relative to a nested directory
// that carries a concrete Next.js project marker. A same-named helper
// elsewhere in the tree is not the framework request hook.
if (normalized === base || normalized === `src/${base}`) return true;
const hookDir = path.dirname(absPath);
const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
? path.dirname(hookDir)
: hookDir;
if (path.resolve(projectRoot) === path.resolve(root)) return true;
return hasNextProjectMarker(projectRoot);
}
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -2028,16 +1650,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -37,30 +37,13 @@ function fileUrlToLocalPath(url) {
}
}
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
// Some agent runners hand a shell-ready URL list to Node as one argv value.
// A browser accepts the spaces as part of one encoded URL, producing a
// plausible scan attributed to a bogus joined path. Expand only when every
// whitespace-delimited token is independently a URL, preserving ordinary
// filesystem paths that contain spaces.
function expandJoinedUrlTargets(targets) {
return targets.flatMap((target) => {
if (!/\s/.test(target)) return [target];
const parts = target.trim().split(/\s+/).filter(Boolean);
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
? parts
: [target];
});
}
// Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
// filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs).
function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
return finding && finding.advisory === true;
}
function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the
failure count so they never block automation. --no-advisory hides them.
Output streams:
Human-readable findings go to stderr so stdout stays available for structured
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
Exit status:
0 Scan completed with no primary findings (advisories may still be listed)
1 At least one requested target could not be scanned
2 Scan completed with primary findings
Operational failure takes precedence when a multi-target scan is partial.
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included)
http(s):// and file:// URLs)
Examples:
impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
};
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
let allFindings = [];
let hadOperationalFailure = false;
const reportLocalScanFailure = (target, error) => {
hadOperationalFailure = true;
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
};
if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
let browserDetector = null;
let browserSetupFailed = false;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
const urlRe = /^(?:https?|file):\/\//i;
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (URL_TARGET_RE.test(target)) {
if (browserSetupFailed) continue;
if (urlRe.test(target)) {
// A file:// URL points at a local artifact, so its design system
// resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target));
} catch (e) {
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
continue;
}
const resolved = path.resolve(target);
let stat;
try { stat = fs.statSync(resolved); }
catch {
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +352,7 @@ async function detectCli() {
}
}
const files = walkDir(resolved, reportLocalScanFailure)
const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +368,7 @@ async function detectCli() {
}
// Build import graph for multi-file awareness
const unreadableFiles = new Set();
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
const graph = buildImportGraph(files);
// Build reverse map: file -> set of files that import it
const importedByMap = new Map();
for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
}
for (const file of files) {
if (unreadableFiles.has(file)) continue;
try {
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
}
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try {
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
}
}
} finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
}
}
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode);
process.exit(primary.length > 0 ? 2 : 0);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode);
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -358,7 +358,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -1961,10 +1961,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -8132,17 +8126,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -8932,16 +8548,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
@@ -394,7 +394,7 @@ async function detectUrl(rawUrl, options = {}) {
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return deriveAdvisoryFlag(item);
return item;
});
}
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]);
}
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
fmt: (m, line) => {
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -9,7 +9,7 @@ import {
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item));
findings.push(item);
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(id);
}
function deriveAdvisoryFlag(item) {
if (item.severity === 'advisory') item.advisory = true;
else delete item.advisory;
return item;
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
@@ -17,7 +11,8 @@ function finding(id, filePath, snippet, line = 0) {
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
// can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules.
return deriveAdvisoryFlag(base);
if (ap.advisory === true) base.advisory = true;
return base;
}
export { getAP, finding, deriveAdvisoryFlag };
export { getAP, finding };
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir, onReadError = null) {
function walkDir(dir) {
const files = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null;
}
function buildImportGraph(files, onReadError = null) {
function buildImportGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const file of files) {
let content;
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const content = fs.readFileSync(file, 'utf-8');
const dir = path.dirname(file);
const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -588,10 +588,9 @@ function getAntipattern(id) {
// Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect
// exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer
// derives its `advisory: true` compatibility/output flag from this set.
// The set is derived from the registry so a rule only needs `advisory: true`.
const ADVISORY_RULE_IDS = new Set(
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').map(rule => rule.id),
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
);
function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -688,10 +688,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -138,20 +138,17 @@ export const IMMEDIATE_TIER_RULES = new Set([
// the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they
// carry neither the current runtime flag nor the canonical advisory severity.
// Current findings are classified by their serialized metadata below.
// This set is the hook's own copy of the registry's `advisory: true` rules,
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// self-contained and testable without loading the detector. Keep it in sync
// with the registry (cli/engine/registry/antipatterns.mjs).
export const ADVISORY_RULES = new Set([
'em-dash-overuse',
]);
export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && (
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
}
export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor;
}
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
}
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl
|| null;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
}
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num);
return true;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return;
}
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
}
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
}
rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId);
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return;
@@ -6392,7 +6326,14 @@
return;
}
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts);
const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
}
return;
}
@@ -6434,7 +6375,7 @@
return;
}
const existingWrapper = findVariantsWrapper(sessionId);
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove();
}
/**
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
removeDiscardStateStylesheet(sessionId);
if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove();
}
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal
//
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
}
if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') {
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
disableInlineEdit();
refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null;
awaitingAcceptResult = null;
setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
});
}
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (!shaderState) return;
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
shaderState = null;
removeStrayShaderNode();
}
function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay();
if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob);
return;
}
@@ -8640,22 +8488,16 @@ void main() {
}
// Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap;
try {
bitmap = await createImageBitmap(blob);
} catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob);
return;
}
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() {
if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(),
};
if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId);
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues };
}
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
});
@@ -8806,7 +8646,7 @@ void main() {
}
function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null;
return {
@@ -8933,7 +8773,7 @@ void main() {
}
function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
}
function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper();
const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one
// wrapper per item, and hiding only one leaves the rest of the
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (wrapper) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
else wrapper.style.display = 'none';
}
setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet();
return;
}
const lateWrappers = discardedWrappers(cleanupSessionId);
if (lateWrappers.length === 0) {
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId);
return;
}
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}
return;
}
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race.
setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId);
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return;
}
removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
if (staleWrapper) location.reload();
}, 2000);
return;
}
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000);
}
hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
}
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
// Which path resumed matters in the journal: an init resume is a fresh
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
function resumeSession(recoveryRevision = liveInteractionRevision) {
const wrapper = document.querySelector('[data-impeccable-variants]');
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's
// generation preflight runs live-wrap with --defer-source-write, so the
// wrapper and every variant reach the DOM in one HMR batch, and the
// deferred-wrapper scout (constructed at init) runs before the variant
// MutationObserver (constructed at Go) on that batch. Finish the same
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
if (state === 'CYCLING') refreshParamsPanel();
saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress');
} else {
queueCheckpoint(resumeReason);
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
queueCheckpoint('browser_resumed');
}
// Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return;
scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
}
});
-6
View File
@@ -1,6 +0,0 @@
# The oracle replays goldens recorded from a POSIX checkout, and a finding's
# snippet carries the fixture's own bytes, so these files have to arrive with
# LF on every platform. `-text` disables end-of-line conversion outright, which
# is also safe for any binary that lands under these trees.
tests/fixtures/** -text
tests/oracle/** -text
+10 -104
View File
@@ -200,20 +200,7 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
if (!repoRoot && targetGitRoot) {
const cwdGitRoot = findGitBoundaryRoot(absCwd);
if (targetGitRoot !== cwdGitRoot) {
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
repoRoot: targetGitRoot,
isMonorepo: false,
};
}
}
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
const targetIsExternal = hasTargetOption(options)
&& targetDir !== absCwd
&& !isPathInside(targetDir, absCwd);
if (targetIsExternal) {
const targetRepoRoot = targetGitRoot || targetDir;
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
repoRoot: targetRepoRoot,
isMonorepo: false,
};
}
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -248,18 +223,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
function findGitBoundaryRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
while (true) {
if (dir === homeDir) return null;
if (hasGitBoundary(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
function resolveTargetPath(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
if (fs.existsSync(abs)) return abs;
return findUniqueBareTarget(cwd, targetPath) || abs;
}
function findUniqueBareTarget(cwd, targetPath) {
const absCwd = path.resolve(cwd);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
const rel = path.relative(absCwd, abs);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
const segments = rel.split(path.sep).filter(Boolean);
if (segments.length !== 1) return null;
const name = segments[0];
const repoRoot = findMonorepoRoot(absCwd);
if (!repoRoot) return null;
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
if (matches.length !== 1) return null;
return path.resolve(repoRoot, matches[0].path);
}
function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try {
const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err;
}
const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0);
}
const ctx = loadContext(
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const ctx = loadContext(process.cwd(), cliOptions);
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}
function pathExistsForTarget(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
return fs.existsSync(abs);
}
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
// Harness project settings are discovered by walking up from the resolved
// project root. Its hook manifest can live at an enclosing git root, so
// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED
// directive. Starting from projectRoot also prevents an explicit target from
// borrowing an unrelated manifest near the caller. The walk itself is the
// authority: do not append repoRoot afterward, because resolveProject can
// retain an outer workspace root for a target inside an independent nested
// Git repository.
function hookManifestSearchRoots(ctx) {
const roots = [];
const seen = new Set();
const add = (root) => {
if (!root) return;
const resolved = path.resolve(root);
if (seen.has(resolved)) return;
seen.add(resolved);
roots.push(resolved);
};
let current = path.resolve(ctx.projectRoot || process.cwd());
const home = path.resolve(os.homedir());
while (true) {
if (current === home) break;
add(current);
if (hasGitBoundary(current)) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return roots;
}
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) {
// A manifest can live above the resolved product. Honor the hook lifecycle
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
for (const root of roots) {
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
* but not auto-patched in v1.
* - "middleware": CSP set dynamically in middleware.{ts,js}.
* Detected but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
const NEXT_MIDDLEWARE_FILES = new Set([
'middleware.ts',
'middleware.js',
'middleware.mjs',
]);
const NEXT_PROXY_FILES = new Set([
'proxy.ts',
'proxy.js',
'proxy.mjs',
]);
const NEXT_CONFIG_FILES = [
'next.config.js',
'next.config.mjs',
'next.config.cjs',
'next.config.ts',
'next.config.mts',
'next.config.cts',
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
function hasNextProjectMarker(projectRoot) {
if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
return ['dependencies', 'devDependencies', 'peerDependencies']
.some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
} catch {
return false;
}
}
function isNextRequestHookFile(root, absPath, relPath, base) {
if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
if (!NEXT_PROXY_FILES.has(base)) return false;
const normalized = relPath.split(path.sep).join('/').toLowerCase();
// Next.js 16 recognizes proxy at the project root or in the optional src/
// directory, alongside app/ or pages/. The scan root is commonly a
// monorepo, so also accept that placement relative to a nested directory
// that carries a concrete Next.js project marker. A same-named helper
// elsewhere in the tree is not the framework request hook.
if (normalized === base || normalized === `src/${base}`) return true;
const hookDir = path.dirname(absPath);
const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
? path.dirname(hookDir)
: hookDir;
if (path.resolve(projectRoot) === path.resolve(root)) return true;
return hasNextProjectMarker(projectRoot);
}
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -2028,16 +1650,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -37,30 +37,13 @@ function fileUrlToLocalPath(url) {
}
}
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
// Some agent runners hand a shell-ready URL list to Node as one argv value.
// A browser accepts the spaces as part of one encoded URL, producing a
// plausible scan attributed to a bogus joined path. Expand only when every
// whitespace-delimited token is independently a URL, preserving ordinary
// filesystem paths that contain spaces.
function expandJoinedUrlTargets(targets) {
return targets.flatMap((target) => {
if (!/\s/.test(target)) return [target];
const parts = target.trim().split(/\s+/).filter(Boolean);
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
? parts
: [target];
});
}
// Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
// filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs).
function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
return finding && finding.advisory === true;
}
function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the
failure count so they never block automation. --no-advisory hides them.
Output streams:
Human-readable findings go to stderr so stdout stays available for structured
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
Exit status:
0 Scan completed with no primary findings (advisories may still be listed)
1 At least one requested target could not be scanned
2 Scan completed with primary findings
Operational failure takes precedence when a multi-target scan is partial.
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included)
http(s):// and file:// URLs)
Examples:
impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
};
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
let allFindings = [];
let hadOperationalFailure = false;
const reportLocalScanFailure = (target, error) => {
hadOperationalFailure = true;
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
};
if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
let browserDetector = null;
let browserSetupFailed = false;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
const urlRe = /^(?:https?|file):\/\//i;
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (URL_TARGET_RE.test(target)) {
if (browserSetupFailed) continue;
if (urlRe.test(target)) {
// A file:// URL points at a local artifact, so its design system
// resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target));
} catch (e) {
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
continue;
}
const resolved = path.resolve(target);
let stat;
try { stat = fs.statSync(resolved); }
catch {
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +352,7 @@ async function detectCli() {
}
}
const files = walkDir(resolved, reportLocalScanFailure)
const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +368,7 @@ async function detectCli() {
}
// Build import graph for multi-file awareness
const unreadableFiles = new Set();
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
const graph = buildImportGraph(files);
// Build reverse map: file -> set of files that import it
const importedByMap = new Map();
for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
}
for (const file of files) {
if (unreadableFiles.has(file)) continue;
try {
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
}
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try {
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
}
}
} finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
}
}
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode);
process.exit(primary.length > 0 ? 2 : 0);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode);
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -358,7 +358,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -1961,10 +1961,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -8132,17 +8126,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -8932,16 +8548,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
@@ -394,7 +394,7 @@ async function detectUrl(rawUrl, options = {}) {
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return deriveAdvisoryFlag(item);
return item;
});
}
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]);
}
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
fmt: (m, line) => {
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -9,7 +9,7 @@ import {
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item));
findings.push(item);
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(id);
}
function deriveAdvisoryFlag(item) {
if (item.severity === 'advisory') item.advisory = true;
else delete item.advisory;
return item;
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
@@ -17,7 +11,8 @@ function finding(id, filePath, snippet, line = 0) {
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
// can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules.
return deriveAdvisoryFlag(base);
if (ap.advisory === true) base.advisory = true;
return base;
}
export { getAP, finding, deriveAdvisoryFlag };
export { getAP, finding };
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir, onReadError = null) {
function walkDir(dir) {
const files = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null;
}
function buildImportGraph(files, onReadError = null) {
function buildImportGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const file of files) {
let content;
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const content = fs.readFileSync(file, 'utf-8');
const dir = path.dirname(file);
const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -588,10 +588,9 @@ function getAntipattern(id) {
// Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect
// exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer
// derives its `advisory: true` compatibility/output flag from this set.
// The set is derived from the registry so a rule only needs `advisory: true`.
const ADVISORY_RULE_IDS = new Set(
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').map(rule => rule.id),
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
);
function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -688,10 +688,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -138,20 +138,17 @@ export const IMMEDIATE_TIER_RULES = new Set([
// the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they
// carry neither the current runtime flag nor the canonical advisory severity.
// Current findings are classified by their serialized metadata below.
// This set is the hook's own copy of the registry's `advisory: true` rules,
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// self-contained and testable without loading the detector. Keep it in sync
// with the registry (cli/engine/registry/antipatterns.mjs).
export const ADVISORY_RULES = new Set([
'em-dash-overuse',
]);
export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && (
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
}
export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor;
}
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
}
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl
|| null;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
}
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num);
return true;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return;
}
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
}
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
}
rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId);
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return;
@@ -6392,7 +6326,14 @@
return;
}
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts);
const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
}
return;
}
@@ -6434,7 +6375,7 @@
return;
}
const existingWrapper = findVariantsWrapper(sessionId);
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove();
}
/**
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
removeDiscardStateStylesheet(sessionId);
if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove();
}
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal
//
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
}
if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') {
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
disableInlineEdit();
refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null;
awaitingAcceptResult = null;
setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
});
}
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (!shaderState) return;
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
shaderState = null;
removeStrayShaderNode();
}
function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay();
if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob);
return;
}
@@ -8640,22 +8488,16 @@ void main() {
}
// Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap;
try {
bitmap = await createImageBitmap(blob);
} catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob);
return;
}
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() {
if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(),
};
if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId);
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues };
}
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
});
@@ -8806,7 +8646,7 @@ void main() {
}
function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null;
return {
@@ -8933,7 +8773,7 @@ void main() {
}
function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
}
function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper();
const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one
// wrapper per item, and hiding only one leaves the rest of the
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (wrapper) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
else wrapper.style.display = 'none';
}
setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet();
return;
}
const lateWrappers = discardedWrappers(cleanupSessionId);
if (lateWrappers.length === 0) {
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId);
return;
}
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}
return;
}
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race.
setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId);
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return;
}
removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
if (staleWrapper) location.reload();
}, 2000);
return;
}
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000);
}
hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
}
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
// Which path resumed matters in the journal: an init resume is a fresh
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
function resumeSession(recoveryRevision = liveInteractionRevision) {
const wrapper = document.querySelector('[data-impeccable-variants]');
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's
// generation preflight runs live-wrap with --defer-source-write, so the
// wrapper and every variant reach the DOM in one HMR batch, and the
// deferred-wrapper scout (constructed at init) runs before the variant
// MutationObserver (constructed at Go) on that batch. Finish the same
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
if (state === 'CYCLING') refreshParamsPanel();
saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress');
} else {
queueCheckpoint(resumeReason);
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
queueCheckpoint('browser_resumed');
}
// Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return;
scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
}
});
+6 -185
View File
@@ -23,7 +23,6 @@ jobs:
runs-on: ubuntu-latest
outputs:
core: ${{ steps.plan.outputs.core }}
rust: ${{ steps.plan.outputs.rust }}
detector: ${{ steps.plan.outputs.detector }}
live: ${{ steps.plan.outputs.live }}
framework: ${{ steps.plan.outputs.framework }}
@@ -93,22 +92,13 @@ jobs:
if: needs.changes.outputs.framework == 'true'
run: bun run test:framework
- name: Rebuild browser detector
if: needs.changes.outputs.detector == 'true'
run: bun run build:browser
- name: Build
run: bun run build
# `bun run build:extension` runs `cargo xtask bundle`: the rule core
# compiled to wasm plus the page JS in browser-bundle/.
- name: Install the pinned toolchain
if: needs.changes.outputs.detector == 'true'
run: rustup show && rustup target add wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.detector == 'true'
- name: Install wasm-pack
if: needs.changes.outputs.detector == 'true'
run: cargo install wasm-pack --locked
- name: Build extension
if: needs.changes.outputs.detector == 'true'
run: bun run build:extension
@@ -121,131 +111,18 @@ jobs:
run: npx --yes web-ext@10 lint --source-dir dist/extension-firefox
- name: Verify generated tracked outputs
# extension/detector/ is gitignored (built by `cargo xtask bundle`);
# it stays listed so a stray tracked copy shows up here.
run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin extension/detector
run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin cli/engine/detect-antipatterns-browser.js extension/detector
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: impeccable-build-node-${{ matrix.node-version }}
name: impeccable-dist-node-${{ matrix.node-version }}
# Ship the packaged zips, not the unpacked Firefox staging tree.
path: |
dist/
!dist/extension-firefox/
retention-days: 7
# The Rust workspace: the engine binary, the rule core, and every crate
# behind them. Everything builds from source with no downloads.
rust:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.rust == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v7
# rust-toolchain.toml names the channel; `rustup show` installs it.
# Never override the toolchain here.
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --workspace --all-targets
- name: Test
run: cargo test --workspace
# The engine ships a windows-x64 binary (release-engine.yml), so the
# workspace has to build and pass its own tests there. Tests that need a
# browser or the oracle skip when those are absent.
rust-windows:
runs-on: windows-latest
needs: changes
if: needs.changes.outputs.rust == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- run: cargo build --workspace --all-targets
- run: cargo test --workspace --no-fail-fast
# Behavior gate: replays the tests/oracle/ goldens against a release build
# of the engine from THIS checkout (so a PR is judged on its own source,
# not on the last published binary). Without this job the oracle only ever
# runs on developer laptops: tests/oracle.test.mjs skips cleanly when no
# binary is present, so the default suite is silent about it on CI.
oracle:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.oracle == 'true' || needs.changes.outputs.rust == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build the engine from source
run: cargo build --release -p impeccable
- name: Replay oracle goldens
env:
IMPECCABLE_BIN: ${{ github.workspace }}/target/release/impeccable
run: node tests/oracle/run.mjs
# Release-order guard (triage decision D4). Verifies that the engine release for
# the pinned ENGINE_VERSION is fully published — the five dist binaries + .sha256
# AND the five @impeccable/cli-<os>-<arch> npm platform packages — before a skill
# release/merge that depends on them. The launcher, npm shim, and
# `impeccable install` all dead-end without those assets.
#
# continue-on-error is a release-time toggle: until the first engine release is
# published, the assets cannot exist and this job would block
# every PR. It emits a loud ::warning instead. Once v<ENGINE_VERSION> is live,
# flip `continue-on-error` to false so a MIS-ORDERED release (skill/CLI ahead of
# the engine) fails CI. release.mjs already hard-fails `release:skill`/`release:cli`.
engine-release-ready:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Check engine release assets for pinned ENGINE_VERSION
id: check
continue-on-error: true
run: node scripts/check-engine-release.mjs
- name: Annotate missing engine release
if: steps.check.outcome != 'success'
run: |
echo "::warning title=Engine release not ready::The engine release for v$(cat ENGINE_VERSION) is not fully published (engine-v$(cat ENGINE_VERSION) release) and/or the @impeccable/cli-<os>-<arch> npm platform packages. Releasing the skill/CLI (or merging) now would dead-end the launcher, the npm shim, and impeccable install. Expected until the first engine release exists; after that, publish the engine + platform packages and flip this job's continue-on-error to false so a mis-ordered release fails CI."
test:
runs-on: ubuntu-latest
needs: test-matrix
@@ -280,16 +157,6 @@ jobs:
- name: Install dependencies
run: bun install
# The live verbs are the engine binary; build it from this checkout so the
# suite tests the branch, not the last published release.
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build the engine
run: cargo build --release -p impeccable
- name: Run remote CLI E2E smoke
run: bun run test:cli-remote-e2e
@@ -345,16 +212,6 @@ jobs:
- name: Install Playwright Chromium
run: npx playwright install chromium
# The live verbs are the engine binary; build it from this checkout so the
# suite tests the branch, not the last published release.
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build the engine
run: cargo build --release -p impeccable
- name: Run live E2E tests
run: bun run test:live-e2e
env:
@@ -431,16 +288,6 @@ jobs:
- name: Install Playwright Chromium
run: npx playwright install chromium
# The live verbs are the engine binary; build it from this checkout so the
# suite tests the branch, not the last published release.
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build the engine
run: cargo build --release -p impeccable
- name: Run live E2E tests
run: bun run test:live-e2e
env:
@@ -513,19 +360,6 @@ jobs:
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: npx playwright install chromium
# The live verbs are the engine binary; build it from this checkout so the
# suite tests the branch, not the last published release.
- name: Install the pinned toolchain
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: rustup show
- uses: Swatinem/rust-cache@v2
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
- name: Build the engine
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: cargo build --release -p impeccable
- name: Run accept cleanup regression
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: |
@@ -590,19 +424,6 @@ jobs:
if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: npx playwright install chromium
# The live verbs are the engine binary; build it from this checkout so the
# suite tests the branch, not the last published release.
- name: Install the pinned toolchain
if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: rustup show
- uses: Swatinem/rust-cache@v2
if: ${{ env.DEEPSEEK_API_KEY != '' }}
- name: Build the engine
if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: cargo build --release -p impeccable
- name: Run Svelte adapter DeepSeek sweep
if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: bun run test:live-svelte-adapter-deepseek
-87
View File
@@ -1,87 +0,0 @@
name: release-engine
# Builds the engine binary for every supported target and publishes them, with
# sha256 sidecars, as the GitHub Release `engine-v<X>` on this repo. That
# release is what the launcher (skill/scripts/impeccable), the npm shim
# (cli/bin/cli.js), `impeccable install`, and `bun run fetch:engine` download.
#
# Trigger: `bun run release:engine` (scripts/release.mjs) verifies
# ENGINE_VERSION, the npm platform-package pins, and a clean tree, then
# pushes the tag. Third-party actions are pinned to commit SHAs so a
# moved tag cannot swap the code this workflow runs.
on:
push:
tags: ['engine-v*']
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- { os: macos-14, target: aarch64-apple-darwin, short: darwin-arm64 }
# No Intel runner: GitHub retired macos-13. Apple's toolchain builds
# x86_64 on an arm64 host natively once the target is installed.
- { os: macos-14, target: x86_64-apple-darwin, short: darwin-x64 }
- { os: ubuntu-latest, target: x86_64-unknown-linux-musl, short: linux-x64 }
- { os: ubuntu-latest, target: aarch64-unknown-linux-musl, short: linux-arm64, cross: true }
- { os: windows-latest, target: x86_64-pc-windows-msvc, short: windows-x64 }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Check the tag matches ENGINE_VERSION
shell: bash
run: |
set -e
want="engine-v$(tr -d '[:space:]' < ENGINE_VERSION)"
[ "$GITHUB_REF_NAME" = "$want" ] || { echo "tag $GITHUB_REF_NAME != $want"; exit 1; }
# rust-toolchain.toml names the channel; `rustup show` installs it.
# Never override the toolchain here.
- name: Install the pinned toolchain
shell: bash
run: rustup show && rustup target add ${{ matrix.target }}
- if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- if: matrix.cross
run: cargo install cross --locked
- name: Build
shell: bash
run: ${{ matrix.cross && 'cross' || 'cargo' }} build --release -p impeccable --target ${{ matrix.target }}
- name: Smoke the binary
if: ${{ !matrix.cross }}
shell: bash
run: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }} engine-probe
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: impeccable-${{ matrix.short }}
path: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }}
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with: { path: artifacts }
- name: Lay out release assets with checksums
run: |
set -e
mkdir -p out
for d in artifacts/impeccable-*; do
short=$(basename "$d" | sed 's/^impeccable-//')
f=$(ls "$d" | head -1)
case "$short" in windows-*) dest="out/impeccable-$short.exe" ;; *) dest="out/impeccable-$short" ;; esac
cp "$d/$f" "$dest"
(cd out && sha256sum "$(basename "$dest")" > "$(basename "$dest").sha256")
done
ls -la out
- name: Publish the GitHub Release
env: { GH_TOKEN: "${{ github.token }}" }
# No --clobber: a published asset is immutable. A re-run against an
# existing release fails on the first existing asset instead of
# silently replacing a binary and its sidecar hash.
run: |
set -e
tag="${GITHUB_REF_NAME}"
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "impeccable engine $tag" \
--notes "Prebuilt impeccable engine binaries ($tag). The launcher, the npm shim and impeccable install download these on first run. Docs: https://impeccable.style" out/* || \
gh release upload "$tag" out/* --repo "$GITHUB_REPOSITORY"
-12
View File
@@ -13,17 +13,10 @@ build/
# can copy them into tmp git repos and assert is-generated behavior.
!tests/framework-fixtures/**/dist/
!tests/framework-fixtures/**/dist/**
# Same for the oracle workspaces: live-html carries a dist/generated.html
# that the generated-file cases point at.
!tests/oracle/workspaces/**/dist/
!tests/oracle/workspaces/**/dist/**
# Build artifacts
*.log
# Cargo (the Rust workspace; Cargo.lock IS tracked, it pins the engine build)
/target/
# OS files
.DS_Store
Thumbs.db
@@ -90,11 +83,6 @@ src/lib/impeccable/__runtime.js
# Extension build artifacts
extension/detector/
# Engine binaries: fetched per platform (scripts/fetch-engine.mjs), never tracked.
# The launcher next to them (skill/scripts/impeccable) is the tracked file.
skill/scripts/bin/
**/skills/impeccable/scripts/bin/
# Legacy design context (pre-v3.1, auto-migrated to PRODUCT.md by load-context.mjs)
.impeccable.md
# Note: PRODUCT.md and DESIGN.md are INTENTIONALLY tracked in this repo —
+10 -104
View File
@@ -200,20 +200,7 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
if (!repoRoot && targetGitRoot) {
const cwdGitRoot = findGitBoundaryRoot(absCwd);
if (targetGitRoot !== cwdGitRoot) {
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
repoRoot: targetGitRoot,
isMonorepo: false,
};
}
}
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
const targetIsExternal = hasTargetOption(options)
&& targetDir !== absCwd
&& !isPathInside(targetDir, absCwd);
if (targetIsExternal) {
const targetRepoRoot = targetGitRoot || targetDir;
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
repoRoot: targetRepoRoot,
isMonorepo: false,
};
}
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -248,18 +223,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
function findGitBoundaryRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
while (true) {
if (dir === homeDir) return null;
if (hasGitBoundary(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
function resolveTargetPath(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
if (fs.existsSync(abs)) return abs;
return findUniqueBareTarget(cwd, targetPath) || abs;
}
function findUniqueBareTarget(cwd, targetPath) {
const absCwd = path.resolve(cwd);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
const rel = path.relative(absCwd, abs);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
const segments = rel.split(path.sep).filter(Boolean);
if (segments.length !== 1) return null;
const name = segments[0];
const repoRoot = findMonorepoRoot(absCwd);
if (!repoRoot) return null;
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
if (matches.length !== 1) return null;
return path.resolve(repoRoot, matches[0].path);
}
function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try {
const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err;
}
const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0);
}
const ctx = loadContext(
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const ctx = loadContext(process.cwd(), cliOptions);
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}
function pathExistsForTarget(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
return fs.existsSync(abs);
}
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
// Harness project settings are discovered by walking up from the resolved
// project root. Its hook manifest can live at an enclosing git root, so
// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED
// directive. Starting from projectRoot also prevents an explicit target from
// borrowing an unrelated manifest near the caller. The walk itself is the
// authority: do not append repoRoot afterward, because resolveProject can
// retain an outer workspace root for a target inside an independent nested
// Git repository.
function hookManifestSearchRoots(ctx) {
const roots = [];
const seen = new Set();
const add = (root) => {
if (!root) return;
const resolved = path.resolve(root);
if (seen.has(resolved)) return;
seen.add(resolved);
roots.push(resolved);
};
let current = path.resolve(ctx.projectRoot || process.cwd());
const home = path.resolve(os.homedir());
while (true) {
if (current === home) break;
add(current);
if (hasGitBoundary(current)) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return roots;
}
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) {
// A manifest can live above the resolved product. Honor the hook lifecycle
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
for (const root of roots) {
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
+4 -52
View File
@@ -18,9 +18,8 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
* but not auto-patched in v1.
* - "middleware": CSP set dynamically in middleware.{ts,js}.
* Detected but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
const NEXT_MIDDLEWARE_FILES = new Set([
'middleware.ts',
'middleware.js',
'middleware.mjs',
]);
const NEXT_PROXY_FILES = new Set([
'proxy.ts',
'proxy.js',
'proxy.mjs',
]);
const NEXT_CONFIG_FILES = [
'next.config.js',
'next.config.mjs',
'next.config.cjs',
'next.config.ts',
'next.config.mts',
'next.config.cts',
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
function hasNextProjectMarker(projectRoot) {
if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
return ['dependencies', 'devDependencies', 'peerDependencies']
.some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
} catch {
return false;
}
}
function isNextRequestHookFile(root, absPath, relPath, base) {
if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
if (!NEXT_PROXY_FILES.has(base)) return false;
const normalized = relPath.split(path.sep).join('/').toLowerCase();
// Next.js 16 recognizes proxy at the project root or in the optional src/
// directory, alongside app/ or pages/. The scan root is commonly a
// monorepo, so also accept that placement relative to a nested directory
// that carries a concrete Next.js project marker. A same-named helper
// elsewhere in the tree is not the framework request hook.
if (normalized === base || normalized === `src/${base}`) return true;
const hookDir = path.dirname(absPath);
const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
? path.dirname(hookDir)
: hookDir;
if (path.resolve(projectRoot) === path.resolve(root)) return true;
return hasNextProjectMarker(projectRoot);
}
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -2028,16 +1650,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -37,30 +37,13 @@ function fileUrlToLocalPath(url) {
}
}
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
// Some agent runners hand a shell-ready URL list to Node as one argv value.
// A browser accepts the spaces as part of one encoded URL, producing a
// plausible scan attributed to a bogus joined path. Expand only when every
// whitespace-delimited token is independently a URL, preserving ordinary
// filesystem paths that contain spaces.
function expandJoinedUrlTargets(targets) {
return targets.flatMap((target) => {
if (!/\s/.test(target)) return [target];
const parts = target.trim().split(/\s+/).filter(Boolean);
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
? parts
: [target];
});
}
// Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
// filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs).
function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
return finding && finding.advisory === true;
}
function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the
failure count so they never block automation. --no-advisory hides them.
Output streams:
Human-readable findings go to stderr so stdout stays available for structured
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
Exit status:
0 Scan completed with no primary findings (advisories may still be listed)
1 At least one requested target could not be scanned
2 Scan completed with primary findings
Operational failure takes precedence when a multi-target scan is partial.
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included)
http(s):// and file:// URLs)
Examples:
impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
};
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
let allFindings = [];
let hadOperationalFailure = false;
const reportLocalScanFailure = (target, error) => {
hadOperationalFailure = true;
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
};
if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
let browserDetector = null;
let browserSetupFailed = false;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
const urlRe = /^(?:https?|file):\/\//i;
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (URL_TARGET_RE.test(target)) {
if (browserSetupFailed) continue;
if (urlRe.test(target)) {
// A file:// URL points at a local artifact, so its design system
// resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target));
} catch (e) {
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
continue;
}
const resolved = path.resolve(target);
let stat;
try { stat = fs.statSync(resolved); }
catch {
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +352,7 @@ async function detectCli() {
}
}
const files = walkDir(resolved, reportLocalScanFailure)
const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +368,7 @@ async function detectCli() {
}
// Build import graph for multi-file awareness
const unreadableFiles = new Set();
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
const graph = buildImportGraph(files);
// Build reverse map: file -> set of files that import it
const importedByMap = new Map();
for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
}
for (const file of files) {
if (unreadableFiles.has(file)) continue;
try {
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
}
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try {
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
}
}
} finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
}
}
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode);
process.exit(primary.length > 0 ? 2 : 0);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode);
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -358,7 +358,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -1961,10 +1961,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -8132,17 +8126,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -8932,16 +8548,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
@@ -394,7 +394,7 @@ async function detectUrl(rawUrl, options = {}) {
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return deriveAdvisoryFlag(item);
return item;
});
}
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]);
}
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
fmt: (m, line) => {
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -9,7 +9,7 @@ import {
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item));
findings.push(item);
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(id);
}
function deriveAdvisoryFlag(item) {
if (item.severity === 'advisory') item.advisory = true;
else delete item.advisory;
return item;
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
@@ -17,7 +11,8 @@ function finding(id, filePath, snippet, line = 0) {
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
// can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules.
return deriveAdvisoryFlag(base);
if (ap.advisory === true) base.advisory = true;
return base;
}
export { getAP, finding, deriveAdvisoryFlag };
export { getAP, finding };
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir, onReadError = null) {
function walkDir(dir) {
const files = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null;
}
function buildImportGraph(files, onReadError = null) {
function buildImportGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const file of files) {
let content;
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const content = fs.readFileSync(file, 'utf-8');
const dir = path.dirname(file);
const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -588,10 +588,9 @@ function getAntipattern(id) {
// Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect
// exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer
// derives its `advisory: true` compatibility/output flag from this set.
// The set is derived from the registry so a rule only needs `advisory: true`.
const ADVISORY_RULE_IDS = new Set(
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').map(rule => rule.id),
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
);
function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -688,10 +688,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
+5 -8
View File
@@ -138,20 +138,17 @@ export const IMMEDIATE_TIER_RULES = new Set([
// the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they
// carry neither the current runtime flag nor the canonical advisory severity.
// Current findings are classified by their serialized metadata below.
// This set is the hook's own copy of the registry's `advisory: true` rules,
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// self-contained and testable without loading the detector. Keep it in sync
// with the registry (cli/engine/registry/antipatterns.mjs).
export const ADVISORY_RULES = new Set([
'em-dash-overuse',
]);
export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && (
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
}
export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor;
}
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
}
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl
|| null;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
}
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num);
return true;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return;
}
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
}
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
}
rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId);
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return;
@@ -6392,7 +6326,14 @@
return;
}
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts);
const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
}
return;
}
@@ -6434,7 +6375,7 @@
return;
}
const existingWrapper = findVariantsWrapper(sessionId);
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return;
}
const wrapper = findVariantsWrapper(currentSessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant;
}
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove();
}
/**
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
removeDiscardStateStylesheet(sessionId);
if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove();
}
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal
//
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
}
if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') {
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
disableInlineEdit();
refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null;
awaitingAcceptResult = null;
setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
});
}
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (!shaderState) return;
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
shaderState = null;
removeStrayShaderNode();
}
function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay();
if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob);
return;
}
@@ -8640,22 +8488,16 @@ void main() {
}
// Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap;
try {
bitmap = await createImageBitmap(blob);
} catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob);
return;
}
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() {
if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(),
};
if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId);
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues };
}
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
});
@@ -8806,7 +8646,7 @@ void main() {
}
function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null;
return {
@@ -8933,7 +8773,7 @@ void main() {
}
function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId);
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
}
function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper();
const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one
// wrapper per item, and hiding only one leaves the rest of the
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (wrapper) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
else wrapper.style.display = 'none';
}
setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet();
return;
}
const lateWrappers = discardedWrappers(cleanupSessionId);
if (lateWrappers.length === 0) {
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId);
return;
}
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}
return;
}
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race.
setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId);
const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return;
}
removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
if (staleWrapper) location.reload();
}, 2000);
return;
}
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000);
}
hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
}
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
// Which path resumed matters in the journal: an init resume is a fresh
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
function resumeSession(recoveryRevision = liveInteractionRevision) {
const wrapper = document.querySelector('[data-impeccable-variants]');
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's
// generation preflight runs live-wrap with --defer-source-write, so the
// wrapper and every variant reach the DOM in one HMR batch, and the
// deferred-wrapper scout (constructed at init) runs before the variant
// MutationObserver (constructed at Go) on that batch. Finish the same
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
if (state === 'CYCLING') refreshParamsPanel();
saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress');
} else {
queueCheckpoint(resumeReason);
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
queueCheckpoint('browser_resumed');
}
// Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return;
scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
}
});
+10 -104
View File
@@ -200,20 +200,7 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir);
if (!repoRoot && targetGitRoot) {
const cwdGitRoot = findGitBoundaryRoot(absCwd);
if (targetGitRoot !== cwdGitRoot) {
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
repoRoot: targetGitRoot,
isMonorepo: false,
};
}
}
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
}
}
if (!repoRoot) {
const targetIsExternal = hasTargetOption(options)
&& targetDir !== absCwd
&& !isPathInside(targetDir, absCwd);
if (targetIsExternal) {
const targetRepoRoot = targetGitRoot || targetDir;
return {
targetDir,
projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
repoRoot: targetRepoRoot,
isMonorepo: false,
};
}
return {
targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
@@ -248,18 +223,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
};
}
function findGitBoundaryRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
while (true) {
if (dir === homeDir) return null;
if (hasGitBoundary(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
function resolveTargetPath(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
if (fs.existsSync(abs)) return abs;
return findUniqueBareTarget(cwd, targetPath) || abs;
}
function findUniqueBareTarget(cwd, targetPath) {
const absCwd = path.resolve(cwd);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath);
const rel = path.relative(absCwd, abs);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
const segments = rel.split(path.sep).filter(Boolean);
if (segments.length !== 1) return null;
const name = segments[0];
const repoRoot = findMonorepoRoot(absCwd);
if (!repoRoot) return null;
const matches = discoverTargetCandidates(repoRoot).filter((candidate) => candidate.name === name);
if (matches.length !== 1) return null;
return path.resolve(repoRoot, matches[0].path);
}
function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath);
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try {
const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err;
}
const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0);
}
const ctx = loadContext(
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const ctx = loadContext(process.cwd(), cliOptions);
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}
function pathExistsForTarget(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
return fs.existsSync(abs);
}
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
// Harness project settings are discovered by walking up from the resolved
// project root. Its hook manifest can live at an enclosing git root, so
// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED
// directive. Starting from projectRoot also prevents an explicit target from
// borrowing an unrelated manifest near the caller. The walk itself is the
// authority: do not append repoRoot afterward, because resolveProject can
// retain an outer workspace root for a target inside an independent nested
// Git repository.
function hookManifestSearchRoots(ctx) {
const roots = [];
const seen = new Set();
const add = (root) => {
if (!root) return;
const resolved = path.resolve(root);
if (seen.has(resolved)) return;
seen.add(resolved);
roots.push(resolved);
};
let current = path.resolve(ctx.projectRoot || process.cwd());
const home = path.resolve(os.homedir());
while (true) {
if (current === home) break;
add(current);
if (hasGitBoundary(current)) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return roots;
}
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) {
// A manifest can live above the resolved product. Honor the hook lifecycle
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
for (const root of roots) {
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers:
* - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
* but not auto-patched in v1.
* - "middleware": CSP set dynamically in middleware.{ts,js}.
* Detected but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
const NEXT_MIDDLEWARE_FILES = new Set([
'middleware.ts',
'middleware.js',
'middleware.mjs',
]);
const NEXT_PROXY_FILES = new Set([
'proxy.ts',
'proxy.js',
'proxy.mjs',
]);
const NEXT_CONFIG_FILES = [
'next.config.js',
'next.config.mjs',
'next.config.cjs',
'next.config.ts',
'next.config.mts',
'next.config.cts',
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
function hasNextProjectMarker(projectRoot) {
if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
return ['dependencies', 'devDependencies', 'peerDependencies']
.some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
} catch {
return false;
}
}
function isNextRequestHookFile(root, absPath, relPath, base) {
if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
if (!NEXT_PROXY_FILES.has(base)) return false;
const normalized = relPath.split(path.sep).join('/').toLowerCase();
// Next.js 16 recognizes proxy at the project root or in the optional src/
// directory, alongside app/ or pages/. The scan root is commonly a
// monorepo, so also accept that placement relative to a nested directory
// that carries a concrete Next.js project marker. A same-named helper
// elsewhere in the tree is not the framework request hook.
if (normalized === base || normalized === `src/${base}`) return true;
const hookDir = path.dirname(absPath);
const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
? path.dirname(hookDir)
: hookDir;
if (path.resolve(projectRoot) === path.resolve(root)) return true;
return hasNextProjectMarker(projectRoot);
}
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes ===
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -2028,16 +1650,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -37,30 +37,13 @@ function fileUrlToLocalPath(url) {
}
}
const URL_TARGET_RE = /^(?:https?|file):\/\//i;
// Some agent runners hand a shell-ready URL list to Node as one argv value.
// A browser accepts the spaces as part of one encoded URL, producing a
// plausible scan attributed to a bogus joined path. Expand only when every
// whitespace-delimited token is independently a URL, preserving ordinary
// filesystem paths that contain spaces.
function expandJoinedUrlTargets(targets) {
return targets.flatMap((target) => {
if (!/\s/.test(target)) return [target];
const parts = target.trim().split(/\s+/).filter(Boolean);
return parts.length > 1 && parts.every(part => URL_TARGET_RE.test(part))
? parts
: [target];
});
}
// Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can
// filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs).
function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
return finding && finding.advisory === true;
}
function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the
failure count so they never block automation. --no-advisory hides them.
Output streams:
Human-readable findings go to stderr so stdout stays available for structured
output. Use --json for JSON on stdout, or redirect text with 2> findings.txt.
Exit status:
0 Scan completed with no primary findings (advisories may still be listed)
1 At least one requested target could not be scanned
2 Scan completed with primary findings
Operational failure takes precedence when a multi-target scan is partial.
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included)
http(s):// and file:// URLs)
Examples:
impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions;
};
const targets = expandJoinedUrlTargets(args.filter(a => !a.startsWith('--')));
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
let allFindings = [];
let hadOperationalFailure = false;
const reportLocalScanFailure = (target, error) => {
hadOperationalFailure = true;
process.stderr.write(`Error: cannot scan ${target}: ${error.message}\n`);
};
if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
let browserDetector = null;
let browserSetupFailed = false;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
const urlRe = /^(?:https?|file):\/\//i;
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (URL_TARGET_RE.test(target)) {
if (browserSetupFailed) continue;
if (urlRe.test(target)) {
// A file:// URL points at a local artifact, so its design system
// resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target));
} catch (e) {
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
continue;
}
const resolved = path.resolve(target);
let stat;
try { stat = fs.statSync(resolved); }
catch {
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +352,7 @@ async function detectCli() {
}
}
const files = walkDir(resolved, reportLocalScanFailure)
const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +368,7 @@ async function detectCli() {
}
// Build import graph for multi-file awareness
const unreadableFiles = new Set();
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
const graph = buildImportGraph(files);
// Build reverse map: file -> set of files that import it
const importedByMap = new Map();
for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
}
for (const file of files) {
if (unreadableFiles.has(file)) continue;
try {
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
// Each file resolves its own project design system (cached by root),
// so a scan spanning sibling projects applies the right rules per file.
const fileOptions = scanOptionsFor(file);
const fileFindings = await detectLocalFile(file, fileOptions);
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
}
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try {
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
const fileOptions = scanOptionsFor(resolved);
allFindings.push(...await detectLocalFile(resolved, fileOptions));
}
}
} finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
}
}
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode);
process.exit(primary.length > 0 ? 2 : 0);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode);
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -358,7 +358,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -1490,7 +1490,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/);
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
@@ -1961,10 +1961,7 @@ function enclosingCssSelector(cssText, index) {
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration.
// Keeping comment length intact preserves indices into the original source.
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
}
if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
// A font is "primary" if it's used by at least 15% of text elements
const PRIMARY_THRESHOLD = 0.15;
for (const [font, count] of fontUsage) {
const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
if (share < PRIMARY_THRESHOLD) continue;
if (!OVERUSED_FONTS.has(font)) continue;
if (isBrandFontOnOwnDomain(font)) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
}
}
@@ -8132,17 +8126,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el),
findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return {
type: f.type || f.id,
category: ap ? ap.category : 'quality',
severity,
severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive
// this strictly from the effective severity.
advisory: severity === 'advisory',
advisory: (ap && ap.advisory === true) || f.advisory === true,
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]);
}
function pseudoElementHostSelector(selector) {
const raw = String(selector || '');
const legacyNames = new Set(['before', 'after', 'first-letter', 'first-line']);
const isNameChar = char => /[a-zA-Z0-9_-]/.test(char || '');
const consumeFunction = (start) => {
let depth = 0;
let quote = '';
for (let i = start; i < raw.length; i += 1) {
const char = raw[i];
if (char === '\\') {
i += 1;
continue;
}
if (quote) {
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '(') depth += 1;
if (char === ')' && --depth === 0) return i + 1;
}
return raw.length;
};
let output = '';
let found = false;
for (let i = 0; i < raw.length;) {
const char = raw[i];
if (char === '\\') {
output += raw.slice(i, Math.min(raw.length, i + 2));
i += 2;
continue;
}
if (char === '"' || char === "'") {
const quote = char;
const start = i;
i += 1;
while (i < raw.length) {
if (raw[i] === '\\') {
i += 2;
continue;
}
const value = raw[i];
i += 1;
if (value === quote) break;
}
output += raw.slice(start, i);
continue;
}
if (char !== ':') {
output += char;
i += 1;
continue;
}
let end = i + 1;
let isPseudoElement = false;
if (raw[end] === ':') {
end += 1;
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = end > nameStart;
} else {
const nameStart = end;
while (isNameChar(raw[end])) end += 1;
isPseudoElement = legacyNames.has(raw.slice(nameStart, end).toLowerCase());
}
if (!isPseudoElement) {
output += char;
i += 1;
continue;
}
if (raw[end] === '(') end = consumeFunction(end);
found = true;
if (!output || /[\s>+~,]/.test(output[output.length - 1])) output += '*';
i = end;
}
if (!found) return null;
return output.trim().replace(/,\s*(?=,|$)/g, '');
}
function selectorNodesForLiveDom(root, selector) {
const raw = String(selector || '').trim();
if (!raw) return null;
const fallback = pseudoElementHostSelector(raw);
if (fallback == null) {
// An empty result from a valid full selector is authoritative. In
// particular, do not broaden inactive :hover/:focus/:not() rules to
// their host element by stripping pseudo-classes.
try { return Array.from(root.querySelectorAll(raw)); }
catch { return null; }
}
// Resolve pseudo-elements to their originating live elements. An attached
// pseudo-element (`.card::before`) belongs to the element before it, while
// a hostless pseudo-element after a combinator (`main > ::before`) belongs
// to a matching element at that position (`main > *`). Replacing every
// pseudo indiscriminately with an empty string leaves the latter as the
// invalid selector `main >` and makes absent hosts indistinguishable from
// selectors the DOM API cannot parse.
if (!fallback || /^[,\s]*$/.test(fallback)) return null;
try { return Array.from(root.querySelectorAll(fallback)); }
catch { return null; }
}
let containerProbeSequence = 0;
function isContainerCssRule(rule) {
return rule?.constructor?.name === 'CSSContainerRule'
|| /^\s*@container\b/i.test(rule?.cssText || '');
}
function styleRuleAppliesToLiveMatches(rule, matches) {
const style = rule?.style;
if (!style || !matches?.length || typeof getComputedStyle !== 'function') return false;
const sequence = ++containerProbeSequence;
const property = `--impeccable-container-probe-${sequence}-${Math.random().toString(36).slice(2)}`;
const value = `impeccable-container-active-${sequence}`;
const previousValue = style.getPropertyValue(property);
const previousPriority = style.getPropertyPriority(property);
try {
style.setProperty(property, value, 'important');
} catch {
return false;
}
const pseudoElements = [...new Set(
String(rule.selectorText || '').match(/::[a-zA-Z-]+(?:\([^)]*\))?/g) || [],
)];
try {
return matches.some(el => [null, ...pseudoElements].some(pseudo => {
try {
const computed = pseudo ? getComputedStyle(el, pseudo) : getComputedStyle(el);
return computed.getPropertyValue(property).trim() === value;
} catch {
return false;
}
}));
} finally {
if (previousValue) style.setProperty(property, previousValue, previousPriority);
else style.removeProperty(property);
}
}
function conditionalCssRuleIsActive(rule) {
const type = Number(rule?.type);
const constructorName = rule?.constructor?.name || '';
if (constructorName === 'CSSMediaRule' || type === 4) {
const condition = rule.conditionText || rule.media?.mediaText || '';
if (!condition || typeof window.matchMedia !== 'function') return true;
try { return window.matchMedia(condition).matches; }
catch { return true; }
}
if (constructorName === 'CSSSupportsRule' || type === 12) {
const condition = rule.conditionText || '';
if (!condition || typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return true;
try { return CSS.supports(condition); }
catch { return true; }
}
return true;
}
function splitCssCommaList(value) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const char of String(value || '')) {
if (escaped) {
current += char;
escaped = false;
continue;
}
if (char === '\\') {
current += char;
escaped = true;
continue;
}
if (quote) {
current += char;
if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
quote = char;
current += char;
continue;
}
if (char === ',') {
parts.push(current);
current = '';
continue;
}
current += char;
}
parts.push(current);
return parts;
}
function normalizeAnimationName(value) {
const name = String(value || '').trim();
if (name.length >= 2 && name[0] === name[name.length - 1] && (name[0] === '"' || name[0] === "'")) {
return name.slice(1, -1);
}
return name;
}
function animationNamesDeclaredByRule(rule) {
const style = rule?.style;
if (!style) return [];
let value = '';
try {
value = style.animationName
|| style.getPropertyValue?.('animation-name')
|| style.webkitAnimationName
|| style.getPropertyValue?.('-webkit-animation-name')
|| '';
} catch {
return [];
}
return splitCssCommaList(value)
.map(normalizeAnimationName)
.filter(name => name && name.toLowerCase() !== 'none');
}
function keyframesRuleName(rule, cssText) {
const constructorName = rule?.constructor?.name || '';
const type = Number(rule?.type);
const isKeyframes = constructorName === 'CSSKeyframesRule'
|| constructorName === 'WebKitCSSKeyframesRule'
|| type === 7
|| /^\s*@(?:-webkit-)?keyframes\b/i.test(cssText);
if (!isKeyframes) return '';
const match = String(cssText || '').match(/^\s*@(?:-webkit-)?keyframes\s+([^\s{]+)/i);
return normalizeAnimationName(rule?.name || match?.[1] || '');
}
function cssPropertyName(property) {
if (property.startsWith('--')) return property;
return property.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}
function resolvedAnimationKeyframes(candidateNames) {
if (typeof document.getAnimations !== 'function') return null;
let animations;
try { animations = document.getAnimations(); }
catch { return null; }
const resolved = new Map();
const metadata = new Set(['offset', 'computedOffset', 'easing', 'composite']);
for (const animation of animations) {
const name = normalizeAnimationName(animation?.animationName || '');
if (!name || !candidateNames.has(name) || resolved.has(name)) continue;
let frames;
try { frames = animation.effect?.getKeyframes?.() || []; }
catch { continue; }
const blocks = [];
for (const frame of frames) {
const rawOffset = Number.isFinite(frame.computedOffset) ? frame.computedOffset : frame.offset;
if (!Number.isFinite(rawOffset)) continue;
const offset = Math.round(rawOffset * 1000000) / 10000;
const declarations = Object.entries(frame)
.filter(([property, value]) => !metadata.has(property) && value != null && value !== '')
.map(([property, value]) => `${cssPropertyName(property)}: ${value};`);
const easing = String(frame.easing || '').trim();
if (easing && easing.toLowerCase() !== 'linear') {
declarations.push(`animation-timing-function: ${easing};`);
}
if (declarations.length === 0) continue;
blocks.push(`${offset}% { ${declarations.join(' ')} }`);
}
if (blocks.length > 0) resolved.set(name, `@keyframes ${name} { ${blocks.join(' ')} }`);
}
return resolved;
}
// Read CSS that is absent from document.outerHTML. Inline <style> blocks are
// already present in the HTML pattern corpus, so limit this walk to linked
// stylesheets. Flatten grouping rules so each declaration keeps its selector,
// and admit only selector rules that target the live DOM. That prevents
// unused utilities from feeding both selector-scoped and page-level checks.
// Same-origin CSS and readable CORS sheets participate; browser security
// exceptions for cross-origin sheets are expected and skipped.
function linkedStylesheetText() {
const parts = [];
const seen = new Set();
const animationNames = new Set();
const keyframeCandidates = new Map();
const appendRules = (rules, requiresAppliedMatch = false) => {
for (const rule of rules) {
if (rule.styleSheet) {
appendSheet(rule.styleSheet);
continue;
}
const cssText = rule.cssText || '';
if (rule.selectorText) {
const matches = selectorNodesForLiveDom(document, rule.selectorText);
// Only declarations with a resolvable live host enter the corpus.
// Unresolvable selectors are uncertain, not evidence that a pattern
// rendered, and retaining them would leak unused CSS into findings.
if (
matches?.length > 0
&& (!requiresAppliedMatch || styleRuleAppliesToLiveMatches(rule, matches))
) {
parts.push(cssText);
for (const name of animationNamesDeclaredByRule(rule)) animationNames.add(name);
}
continue;
}
let nested = [];
let hasNestedRules = false;
try {
const ruleList = rule.cssRules;
hasNestedRules = ruleList != null;
nested = Array.from(ruleList || []);
} catch {
continue;
}
const keyframesName = keyframesRuleName(rule, cssText);
if (keyframesName) {
// Keyframes do not merge: when a name is defined more than once, the
// later effective definition replaces the earlier one.
keyframeCandidates.set(keyframesName, {
name: keyframesName,
cssText,
});
continue;
}
if (hasNestedRules) {
if (!conditionalCssRuleIsActive(rule)) continue;
appendRules(nested, requiresAppliedMatch || isContainerCssRule(rule));
continue;
}
// Other selector-less leaf at-rules cannot be tied to a rendered node.
}
};
const appendSheet = (sheet) => {
if (!sheet || seen.has(sheet)) return;
seen.add(sheet);
let rules;
try { rules = Array.from(sheet.cssRules || sheet.rules || []); }
catch { return; }
appendRules(rules);
};
let sheets;
try { sheets = Array.from(document.styleSheets || []); }
catch { return ''; }
for (const sheet of sheets) {
const owner = sheet.ownerNode;
if (owner?.tagName?.toLowerCase() !== 'link') continue;
if (!/\bstylesheet\b/i.test(owner.getAttribute?.('rel') || '')) continue;
appendSheet(sheet);
}
// Motion checks need the effective body of a live animation's keyframes.
// Let the browser resolve duplicate names across source order, imports,
// conditional groups, and cascade layers, then serialize those computed
// frames back into the pattern corpus. Browsers also make container-nested
// keyframes globally available, so lexical grouping is not a reliable
// activity signal. When the Web Animations API is unavailable, fall back to
// the last source-order definition referenced by a retained linked rule.
const resolvedKeyframes = resolvedAnimationKeyframes(new Set(keyframeCandidates.keys()));
if (resolvedKeyframes) {
parts.push(...resolvedKeyframes.values());
} else {
for (const candidate of keyframeCandidates.values()) {
if (!animationNames.has(candidate.name)) continue;
parts.push(candidate.cssText);
}
}
return parts.join('\n');
}
function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
@@ -8932,16 +8548,18 @@ if (IS_BROWSER) {
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML;
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector);
if (!matches) return false;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return matches.some(el => !scopedIgnoreActive(el, f.id));
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.mjs';
@@ -394,7 +394,7 @@ async function detectUrl(rawUrl, options = {}) {
// Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity;
return deriveAdvisoryFlag(item);
return item;
});
}
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]);
}
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
fmt: (m, line) => {
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -9,7 +9,7 @@ import {
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { deriveAdvisoryFlag, finding } from '../../findings.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default.
if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item));
findings.push(item);
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(id);
}
function deriveAdvisoryFlag(item) {
if (item.severity === 'advisory') item.advisory = true;
else delete item.advisory;
return item;
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
const base = { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet };
@@ -17,7 +11,8 @@ function finding(id, filePath, snippet, line = 0) {
// failures. Carry the flag on the finding so every consumer (CLI, JSON, hook)
// can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules.
return deriveAdvisoryFlag(base);
if (ap.advisory === true) base.advisory = true;
return base;
}
export { getAP, finding, deriveAdvisoryFlag };
export { getAP, finding };
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir, onReadError = null) {
function walkDir(dir) {
const files = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null;
}
function buildImportGraph(files, onReadError = null) {
function buildImportGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const file of files) {
let content;
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const content = fs.readFileSync(file, 'utf-8');
const dir = path.dirname(file);
const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory',
advisory: true,
name: 'Em-dash overuse',
description:
'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text — never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
@@ -588,10 +588,9 @@ function getAntipattern(id) {
// Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect
// exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer
// derives its `advisory: true` compatibility/output flag from this set.
// The set is derived from the registry so a rule only needs `advisory: true`.
const ADVISORY_RULE_IDS = new Set(
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').map(rule => rule.id),
ANTIPATTERNS.filter(rule => rule.advisory === true).map(rule => rule.id),
);
function isAdvisoryRule(id) {

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