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 187501 deletions
+10 -104
View File
@@ -200,20 +200,7 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
function resolveProject(cwd = process.cwd(), options = {}) { function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd); const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options); const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir); 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) { if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd); const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
} }
} }
if (!repoRoot) { 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 { return {
targetDir, targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, 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) { function isPathInside(candidate, root) {
const rel = path.relative(root, candidate); const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); 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 = {}) { function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null; const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd; if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath); const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try { try {
const stat = fs.statSync(abs); const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs); return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err; throw err;
} }
const targetProvided = hasTargetOption(cliOptions); const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions); const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) { if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0); process.exit(0);
} }
const ctx = loadContext( const ctx = loadContext(process.cwd(), cliOptions);
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const updateDirective = await computeUpdateDirective(); const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) { if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); 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({ const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'], 'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'], codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); 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) { function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none'; return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none'; if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) { const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
// A manifest can live above the resolved product. Honor the hook lifecycle for (const root of roots) {
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
for (const rel of manifests) { for (const rel of manifests) {
const raw = readJson(path.join(root, rel)); const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) { if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers: * Covers:
* - Inline Next.js headers() with CSP string * - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers * - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or * - "middleware": CSP set dynamically in middleware.{ts,js}.
* Next.js 16's proxy.{ts,js,mjs} convention. Detected * Detected but not auto-patched in v1.
* but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in * - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1. * layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed. * - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/, /\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 MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\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. * @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }} * @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes === // === 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); hits.middleware.push(relPath);
} }
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { 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 // Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that // separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can // 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 // filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs). // findings.mjs).
function isAdvisory(finding) { function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory')); return finding && finding.advisory === true;
} }
function partitionAdvisory(findings) { function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the 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. 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: Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS) HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected; URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included) http(s):// and file:// URLs)
Examples: Examples:
impeccable detect src/ impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache }); const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions; 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); } if (helpMode) { printUsage(); process.exit(0); }
let allFindings = []; 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) { if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor); allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a // real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlRe = /^(?:https?|file):\/\//i;
let browserDetector = null; const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
let browserSetupFailed = false; const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
try { try {
for (const target of paths) { for (const target of paths) {
if (URL_TARGET_RE.test(target)) { if (urlRe.test(target)) {
if (browserSetupFailed) continue;
// A file:// URL points at a local artifact, so its design system // 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 // resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never // local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // 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)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; 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 // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); 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 (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; 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 // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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 classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs'; import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.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) // Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding(). // overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity; 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]); 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 = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { 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)), 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) => { 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] || '?'}`; } },
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] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { 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), 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'; } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs'; import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.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 { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { import {
checkElementBorders, checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that // severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default. // overrides the registry default.
if (f.severity) item.severity = f.severity; if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item)); findings.push(item);
} }
// Text-content analyzers (em-dash overuse, marketing buzzwords, // Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex // numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(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) { function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id); 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 }; 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) // 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 // can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules. // 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, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_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); 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); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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: // Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect // 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. // exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer // The set is derived from the registry so a rule only needs `advisory: true`.
// derives its `advisory: true` compatibility/output flag from this set.
const ADVISORY_RULE_IDS = new Set( 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) { function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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. // the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`: // A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } } // { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they // This set is the hook's own copy of the registry's `advisory: true` rules,
// carry neither the current runtime flag nor the canonical advisory severity. // mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// Current findings are classified by their serialized metadata below. // 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([ export const ADVISORY_RULES = new Set([
'em-dash-overuse', 'em-dash-overuse',
]); ]);
export function isAdvisoryFinding(finding) { export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern); const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && ( return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
} }
export const DEFAULT_CONFIG = Object.freeze({ export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; 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; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .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; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); 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) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(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"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { 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; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* 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) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); 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) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // 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) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } 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 // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // 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() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; 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'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 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 paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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 }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); 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) { 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 accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // 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). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; 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 (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// 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();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// 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();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// 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');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); 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 = {}) { function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd); const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options); const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir); 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) { if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd); const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
} }
} }
if (!repoRoot) { 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 { return {
targetDir, targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, 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) { function isPathInside(candidate, root) {
const rel = path.relative(root, candidate); const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); 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 = {}) { function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null; const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd; if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath); const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try { try {
const stat = fs.statSync(abs); const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs); return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err; throw err;
} }
const targetProvided = hasTargetOption(cliOptions); const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions); const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) { if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0); process.exit(0);
} }
const ctx = loadContext( const ctx = loadContext(process.cwd(), cliOptions);
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const updateDirective = await computeUpdateDirective(); const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) { if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); 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({ const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'], 'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'], codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); 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) { function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none'; return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none'; if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) { const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
// A manifest can live above the resolved product. Honor the hook lifecycle for (const root of roots) {
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
for (const rel of manifests) { for (const rel of manifests) {
const raw = readJson(path.join(root, rel)); const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) { if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers: * Covers:
* - Inline Next.js headers() with CSP string * - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers * - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or * - "middleware": CSP set dynamically in middleware.{ts,js}.
* Next.js 16's proxy.{ts,js,mjs} convention. Detected * Detected but not auto-patched in v1.
* but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in * - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1. * layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed. * - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/, /\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 MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\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. * @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }} * @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes === // === 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); hits.middleware.push(relPath);
} }
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { 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 // Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that // separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can // 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 // filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs). // findings.mjs).
function isAdvisory(finding) { function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory')); return finding && finding.advisory === true;
} }
function partitionAdvisory(findings) { function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the 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. 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: Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS) HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected; URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included) http(s):// and file:// URLs)
Examples: Examples:
impeccable detect src/ impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache }); const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions; 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); } if (helpMode) { printUsage(); process.exit(0); }
let allFindings = []; 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) { if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor); allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a // real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlRe = /^(?:https?|file):\/\//i;
let browserDetector = null; const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
let browserSetupFailed = false; const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
try { try {
for (const target of paths) { for (const target of paths) {
if (URL_TARGET_RE.test(target)) { if (urlRe.test(target)) {
if (browserSetupFailed) continue;
// A file:// URL points at a local artifact, so its design system // 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 // resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never // local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // 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)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; 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 // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); 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 (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; 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 // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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 classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs'; import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.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) // Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding(). // overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity; 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]); 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 = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { 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)), 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) => { 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] || '?'}`; } },
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] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { 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), 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'; } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs'; import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.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 { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { import {
checkElementBorders, checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that // severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default. // overrides the registry default.
if (f.severity) item.severity = f.severity; if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item)); findings.push(item);
} }
// Text-content analyzers (em-dash overuse, marketing buzzwords, // Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex // numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(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) { function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id); 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 }; 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) // 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 // can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules. // 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, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_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); 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); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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: // Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect // 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. // exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer // The set is derived from the registry so a rule only needs `advisory: true`.
// derives its `advisory: true` compatibility/output flag from this set.
const ADVISORY_RULE_IDS = new Set( 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) { function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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. // the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`: // A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } } // { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they // This set is the hook's own copy of the registry's `advisory: true` rules,
// carry neither the current runtime flag nor the canonical advisory severity. // mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// Current findings are classified by their serialized metadata below. // 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([ export const ADVISORY_RULES = new Set([
'em-dash-overuse', 'em-dash-overuse',
]); ]);
export function isAdvisoryFinding(finding) { export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern); const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && ( return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
} }
export const DEFAULT_CONFIG = Object.freeze({ export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; 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; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .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; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); 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) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(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"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { 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; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* 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) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); 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) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // 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) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } 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 // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // 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() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; 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'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 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 paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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 }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); 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) { 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 accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // 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). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; 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 (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// 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();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// 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();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// 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');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); 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 = {}) { function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd); const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options); const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir); 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) { if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd); const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
} }
} }
if (!repoRoot) { 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 { return {
targetDir, targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, 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) { function isPathInside(candidate, root) {
const rel = path.relative(root, candidate); const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); 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 = {}) { function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null; const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd; if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath); const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try { try {
const stat = fs.statSync(abs); const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs); return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err; throw err;
} }
const targetProvided = hasTargetOption(cliOptions); const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions); const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) { if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0); process.exit(0);
} }
const ctx = loadContext( const ctx = loadContext(process.cwd(), cliOptions);
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const updateDirective = await computeUpdateDirective(); const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) { if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); 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({ const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'], 'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'], codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); 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) { function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none'; return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none'; if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) { const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
// A manifest can live above the resolved product. Honor the hook lifecycle for (const root of roots) {
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
for (const rel of manifests) { for (const rel of manifests) {
const raw = readJson(path.join(root, rel)); const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) { if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers: * Covers:
* - Inline Next.js headers() with CSP string * - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers * - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or * - "middleware": CSP set dynamically in middleware.{ts,js}.
* Next.js 16's proxy.{ts,js,mjs} convention. Detected * Detected but not auto-patched in v1.
* but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in * - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1. * layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed. * - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/, /\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 MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\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. * @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }} * @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes === // === 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); hits.middleware.push(relPath);
} }
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { 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 // Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that // separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can // 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 // filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs). // findings.mjs).
function isAdvisory(finding) { function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory')); return finding && finding.advisory === true;
} }
function partitionAdvisory(findings) { function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the 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. 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: Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS) HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected; URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included) http(s):// and file:// URLs)
Examples: Examples:
impeccable detect src/ impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache }); const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions; 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); } if (helpMode) { printUsage(); process.exit(0); }
let allFindings = []; 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) { if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor); allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a // real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlRe = /^(?:https?|file):\/\//i;
let browserDetector = null; const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
let browserSetupFailed = false; const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
try { try {
for (const target of paths) { for (const target of paths) {
if (URL_TARGET_RE.test(target)) { if (urlRe.test(target)) {
if (browserSetupFailed) continue;
// A file:// URL points at a local artifact, so its design system // 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 // resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never // local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // 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)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; 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 // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); 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 (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; 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 // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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 classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs'; import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.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) // Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding(). // overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity; 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]); 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 = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { 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)), 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) => { 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] || '?'}`; } },
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] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { 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), 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'; } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs'; import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.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 { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { import {
checkElementBorders, checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that // severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default. // overrides the registry default.
if (f.severity) item.severity = f.severity; if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item)); findings.push(item);
} }
// Text-content analyzers (em-dash overuse, marketing buzzwords, // Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex // numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(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) { function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id); 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 }; 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) // 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 // can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules. // 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, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_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); 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); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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: // Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect // 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. // exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer // The set is derived from the registry so a rule only needs `advisory: true`.
// derives its `advisory: true` compatibility/output flag from this set.
const ADVISORY_RULE_IDS = new Set( 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) { function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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. // the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`: // A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } } // { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they // This set is the hook's own copy of the registry's `advisory: true` rules,
// carry neither the current runtime flag nor the canonical advisory severity. // mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// Current findings are classified by their serialized metadata below. // 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([ export const ADVISORY_RULES = new Set([
'em-dash-overuse', 'em-dash-overuse',
]); ]);
export function isAdvisoryFinding(finding) { export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern); const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && ( return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
} }
export const DEFAULT_CONFIG = Object.freeze({ export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; 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; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .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; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); 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) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(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"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { 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; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* 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) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); 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) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // 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) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } 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 // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // 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() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; 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'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 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 paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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 }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); 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) { 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 accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // 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). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; 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 (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// 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();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// 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();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// 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');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); 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 = {}) { function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd); const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options); const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir); 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) { if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd); const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
} }
} }
if (!repoRoot) { 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 { return {
targetDir, targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, 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) { function isPathInside(candidate, root) {
const rel = path.relative(root, candidate); const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); 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 = {}) { function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null; const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd; if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath); const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try { try {
const stat = fs.statSync(abs); const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs); return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err; throw err;
} }
const targetProvided = hasTargetOption(cliOptions); const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions); const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) { if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0); process.exit(0);
} }
const ctx = loadContext( const ctx = loadContext(process.cwd(), cliOptions);
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const updateDirective = await computeUpdateDirective(); const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) { if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); 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({ const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'], 'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'], codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); 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) { function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none'; return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none'; if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) { const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
// A manifest can live above the resolved product. Honor the hook lifecycle for (const root of roots) {
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
for (const rel of manifests) { for (const rel of manifests) {
const raw = readJson(path.join(root, rel)); const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) { if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers: * Covers:
* - Inline Next.js headers() with CSP string * - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers * - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or * - "middleware": CSP set dynamically in middleware.{ts,js}.
* Next.js 16's proxy.{ts,js,mjs} convention. Detected * Detected but not auto-patched in v1.
* but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in * - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1. * layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed. * - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/, /\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 MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\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. * @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }} * @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes === // === 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); hits.middleware.push(relPath);
} }
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { 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 // Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that // separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can // 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 // filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs). // findings.mjs).
function isAdvisory(finding) { function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory')); return finding && finding.advisory === true;
} }
function partitionAdvisory(findings) { function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the 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. 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: Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS) HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected; URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included) http(s):// and file:// URLs)
Examples: Examples:
impeccable detect src/ impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache }); const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions; 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); } if (helpMode) { printUsage(); process.exit(0); }
let allFindings = []; 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) { if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor); allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a // real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlRe = /^(?:https?|file):\/\//i;
let browserDetector = null; const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
let browserSetupFailed = false; const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
try { try {
for (const target of paths) { for (const target of paths) {
if (URL_TARGET_RE.test(target)) { if (urlRe.test(target)) {
if (browserSetupFailed) continue;
// A file:// URL points at a local artifact, so its design system // 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 // resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never // local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // 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)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; 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 // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); 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 (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; 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 // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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 classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs'; import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.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) // Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding(). // overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity; 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]); 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 = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { 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)), 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) => { 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] || '?'}`; } },
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] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { 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), 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'; } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs'; import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.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 { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { import {
checkElementBorders, checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that // severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default. // overrides the registry default.
if (f.severity) item.severity = f.severity; if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item)); findings.push(item);
} }
// Text-content analyzers (em-dash overuse, marketing buzzwords, // Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex // numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(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) { function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id); 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 }; 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) // 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 // can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules. // 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, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_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); 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); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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: // Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect // 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. // exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer // The set is derived from the registry so a rule only needs `advisory: true`.
// derives its `advisory: true` compatibility/output flag from this set.
const ADVISORY_RULE_IDS = new Set( 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) { function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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. // the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`: // A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } } // { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they // This set is the hook's own copy of the registry's `advisory: true` rules,
// carry neither the current runtime flag nor the canonical advisory severity. // mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// Current findings are classified by their serialized metadata below. // 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([ export const ADVISORY_RULES = new Set([
'em-dash-overuse', 'em-dash-overuse',
]); ]);
export function isAdvisoryFinding(finding) { export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern); const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && ( return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
} }
export const DEFAULT_CONFIG = Object.freeze({ export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; 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; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .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; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); 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) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(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"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { 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; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* 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) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); 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) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // 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) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } 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 // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // 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() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; 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'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 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 paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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 }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); 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) { 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 accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // 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). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; 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 (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// 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();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// 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();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// 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');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); 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 = {}) { function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd); const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options); const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir); 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) { if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd); const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
} }
} }
if (!repoRoot) { 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 { return {
targetDir, targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, 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) { function isPathInside(candidate, root) {
const rel = path.relative(root, candidate); const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); 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 = {}) { function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null; const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd; if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath); const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try { try {
const stat = fs.statSync(abs); const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs); return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err; throw err;
} }
const targetProvided = hasTargetOption(cliOptions); const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions); const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) { if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0); process.exit(0);
} }
const ctx = loadContext( const ctx = loadContext(process.cwd(), cliOptions);
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const updateDirective = await computeUpdateDirective(); const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) { if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); 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({ const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'], 'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'], codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); 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) { function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none'; return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none'; if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) { const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
// A manifest can live above the resolved product. Honor the hook lifecycle for (const root of roots) {
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
for (const rel of manifests) { for (const rel of manifests) {
const raw = readJson(path.join(root, rel)); const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) { if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers: * Covers:
* - Inline Next.js headers() with CSP string * - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers * - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or * - "middleware": CSP set dynamically in middleware.{ts,js}.
* Next.js 16's proxy.{ts,js,mjs} convention. Detected * Detected but not auto-patched in v1.
* but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in * - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1. * layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed. * - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/, /\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 MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\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. * @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }} * @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes === // === 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); hits.middleware.push(relPath);
} }
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { 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 // Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that // separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can // 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 // filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs). // findings.mjs).
function isAdvisory(finding) { function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory')); return finding && finding.advisory === true;
} }
function partitionAdvisory(findings) { function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the 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. 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: Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS) HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected; URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included) http(s):// and file:// URLs)
Examples: Examples:
impeccable detect src/ impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache }); const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions; 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); } if (helpMode) { printUsage(); process.exit(0); }
let allFindings = []; 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) { if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor); allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a // real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlRe = /^(?:https?|file):\/\//i;
let browserDetector = null; const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
let browserSetupFailed = false; const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
try { try {
for (const target of paths) { for (const target of paths) {
if (URL_TARGET_RE.test(target)) { if (urlRe.test(target)) {
if (browserSetupFailed) continue;
// A file:// URL points at a local artifact, so its design system // 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 // resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never // local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // 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)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; 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 // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); 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 (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; 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 // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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 classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs'; import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.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) // Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding(). // overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity; 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]); 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 = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { 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)), 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) => { 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] || '?'}`; } },
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] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { 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), 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'; } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs'; import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.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 { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { import {
checkElementBorders, checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that // severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default. // overrides the registry default.
if (f.severity) item.severity = f.severity; if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item)); findings.push(item);
} }
// Text-content analyzers (em-dash overuse, marketing buzzwords, // Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex // numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(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) { function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id); 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 }; 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) // 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 // can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules. // 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, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_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); 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); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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: // Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect // 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. // exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer // The set is derived from the registry so a rule only needs `advisory: true`.
// derives its `advisory: true` compatibility/output flag from this set.
const ADVISORY_RULE_IDS = new Set( 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) { function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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. // the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`: // A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } } // { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they // This set is the hook's own copy of the registry's `advisory: true` rules,
// carry neither the current runtime flag nor the canonical advisory severity. // mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// Current findings are classified by their serialized metadata below. // 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([ export const ADVISORY_RULES = new Set([
'em-dash-overuse', 'em-dash-overuse',
]); ]);
export function isAdvisoryFinding(finding) { export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern); const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && ( return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
} }
export const DEFAULT_CONFIG = Object.freeze({ export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; 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; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .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; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); 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) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(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"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { 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; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* 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) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); 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) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // 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) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } 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 // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // 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() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; 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'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 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 paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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 }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); 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) { 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 accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // 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). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; 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 (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// 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();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// 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();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// 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');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
+6 -185
View File
@@ -23,7 +23,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
core: ${{ steps.plan.outputs.core }} core: ${{ steps.plan.outputs.core }}
rust: ${{ steps.plan.outputs.rust }}
detector: ${{ steps.plan.outputs.detector }} detector: ${{ steps.plan.outputs.detector }}
live: ${{ steps.plan.outputs.live }} live: ${{ steps.plan.outputs.live }}
framework: ${{ steps.plan.outputs.framework }} framework: ${{ steps.plan.outputs.framework }}
@@ -93,22 +92,13 @@ jobs:
if: needs.changes.outputs.framework == 'true' if: needs.changes.outputs.framework == 'true'
run: bun run test:framework run: bun run test:framework
- name: Rebuild browser detector
if: needs.changes.outputs.detector == 'true'
run: bun run build:browser
- name: Build - name: Build
run: bun run 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 - name: Build extension
if: needs.changes.outputs.detector == 'true' if: needs.changes.outputs.detector == 'true'
run: bun run build:extension run: bun run build:extension
@@ -121,131 +111,18 @@ jobs:
run: npx --yes web-ext@10 lint --source-dir dist/extension-firefox run: npx --yes web-ext@10 lint --source-dir dist/extension-firefox
- name: Verify generated tracked outputs - name: Verify generated tracked outputs
# extension/detector/ is gitignored (built by `cargo xtask bundle`); run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin cli/engine/detect-antipatterns-browser.js extension/detector
# 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
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: 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. # Ship the packaged zips, not the unpacked Firefox staging tree.
path: | path: |
dist/ dist/
!dist/extension-firefox/ !dist/extension-firefox/
retention-days: 7 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: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test-matrix needs: test-matrix
@@ -280,16 +157,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install 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 - name: Run remote CLI E2E smoke
run: bun run test:cli-remote-e2e run: bun run test:cli-remote-e2e
@@ -345,16 +212,6 @@ jobs:
- name: Install Playwright Chromium - name: Install Playwright Chromium
run: npx playwright install 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 - name: Run live E2E tests
run: bun run test:live-e2e run: bun run test:live-e2e
env: env:
@@ -431,16 +288,6 @@ jobs:
- name: Install Playwright Chromium - name: Install Playwright Chromium
run: npx playwright install 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 - name: Run live E2E tests
run: bun run test:live-e2e run: bun run test:live-e2e
env: env:
@@ -513,19 +360,6 @@ jobs:
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: npx playwright install 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
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 - name: Run accept cleanup regression
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: | run: |
@@ -590,19 +424,6 @@ jobs:
if: ${{ env.DEEPSEEK_API_KEY != '' }} if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: npx playwright install 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
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 - name: Run Svelte adapter DeepSeek sweep
if: ${{ env.DEEPSEEK_API_KEY != '' }} if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: bun run test:live-svelte-adapter-deepseek 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.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. # can copy them into tmp git repos and assert is-generated behavior.
!tests/framework-fixtures/**/dist/ !tests/framework-fixtures/**/dist/
!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 # Build artifacts
*.log *.log
# Cargo (the Rust workspace; Cargo.lock IS tracked, it pins the engine build)
/target/
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -90,11 +83,6 @@ src/lib/impeccable/__runtime.js
# Extension build artifacts # Extension build artifacts
extension/detector/ 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) # Legacy design context (pre-v3.1, auto-migrated to PRODUCT.md by load-context.mjs)
.impeccable.md .impeccable.md
# Note: PRODUCT.md and DESIGN.md are INTENTIONALLY tracked in this repo — # 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 = {}) { function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd); const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options); const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir); 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) { if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd); const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
} }
} }
if (!repoRoot) { 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 { return {
targetDir, targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, 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) { function isPathInside(candidate, root) {
const rel = path.relative(root, candidate); const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); 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 = {}) { function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null; const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd; if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath); const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try { try {
const stat = fs.statSync(abs); const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs); return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err; throw err;
} }
const targetProvided = hasTargetOption(cliOptions); const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions); const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) { if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0); process.exit(0);
} }
const ctx = loadContext( const ctx = loadContext(process.cwd(), cliOptions);
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const updateDirective = await computeUpdateDirective(); const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) { if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); 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({ const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'], 'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'], codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); 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) { function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none'; return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none'; if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) { const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
// A manifest can live above the resolved product. Honor the hook lifecycle for (const root of roots) {
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
for (const rel of manifests) { for (const rel of manifests) {
const raw = readJson(path.join(root, rel)); const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) { if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
+4 -52
View File
@@ -18,9 +18,8 @@
* Covers: * Covers:
* - Inline Next.js headers() with CSP string * - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers * - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or * - "middleware": CSP set dynamically in middleware.{ts,js}.
* Next.js 16's proxy.{ts,js,mjs} convention. Detected * Detected but not auto-patched in v1.
* but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in * - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1. * layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed. * - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/, /\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 MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\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. * @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }} * @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes === // === 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); hits.middleware.push(relPath);
} }
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { 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 // Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that // separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can // 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 // filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs). // findings.mjs).
function isAdvisory(finding) { function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory')); return finding && finding.advisory === true;
} }
function partitionAdvisory(findings) { function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the 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. 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: Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS) HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected; URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included) http(s):// and file:// URLs)
Examples: Examples:
impeccable detect src/ impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache }); const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions; 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); } if (helpMode) { printUsage(); process.exit(0); }
let allFindings = []; 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) { if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor); allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a // real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlRe = /^(?:https?|file):\/\//i;
let browserDetector = null; const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
let browserSetupFailed = false; const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
try { try {
for (const target of paths) { for (const target of paths) {
if (URL_TARGET_RE.test(target)) { if (urlRe.test(target)) {
if (browserSetupFailed) continue;
// A file:// URL points at a local artifact, so its design system // 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 // resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never // local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // 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)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; 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 // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); 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 (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; 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 // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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 classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs'; import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.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) // Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding(). // overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity; 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]); 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 = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { 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)), 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) => { 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] || '?'}`; } },
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] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { 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), 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'; } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs'; import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.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 { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { import {
checkElementBorders, checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that // severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default. // overrides the registry default.
if (f.severity) item.severity = f.severity; if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item)); findings.push(item);
} }
// Text-content analyzers (em-dash overuse, marketing buzzwords, // Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex // numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(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) { function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id); 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 }; 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) // 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 // can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules. // 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, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_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); 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); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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: // Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect // 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. // exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer // The set is derived from the registry so a rule only needs `advisory: true`.
// derives its `advisory: true` compatibility/output flag from this set.
const ADVISORY_RULE_IDS = new Set( 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) { function isAdvisoryRule(id) {
@@ -217,7 +217,7 @@ function checkColors(opts) {
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' '); const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -4023,17 +4020,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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. // the agent is never nagged about a taste call a human might make on purpose.
// A project opts back in with `.impeccable/config.json`: // A project opts back in with `.impeccable/config.json`:
// { "detector": { "advisoryRules": "include" } } // { "detector": { "advisoryRules": "include" } }
// This legacy id fallback keeps older detector findings recognizable when they // This set is the hook's own copy of the registry's `advisory: true` rules,
// carry neither the current runtime flag nor the canonical advisory severity. // mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
// Current findings are classified by their serialized metadata below. // 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([ export const ADVISORY_RULES = new Set([
'em-dash-overuse', 'em-dash-overuse',
]); ]);
export function isAdvisoryFinding(finding) { export function isAdvisoryFinding(finding) {
const id = finding && normalizeIgnoreRule(finding.antipattern); const id = finding && normalizeIgnoreRule(finding.antipattern);
return Boolean(id && ( return Boolean(id && (ADVISORY_RULES.has(id) || finding.advisory === true));
ADVISORY_RULES.has(id)
|| finding.advisory === true
|| finding.severity === 'advisory'
));
} }
export const DEFAULT_CONFIG = Object.freeze({ export const DEFAULT_CONFIG = Object.freeze({
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; 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; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .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; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); 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) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(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"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { 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; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* 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) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); 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) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // 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) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } 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 // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // 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() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; 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'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); 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 paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 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 }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); 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) { 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 accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // 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). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; 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 (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// 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();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// 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();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// 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');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); 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 = {}) { function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd); const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options); const targetDir = resolveTargetDir(absCwd, options);
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
let repoRoot = findMonorepoRoot(targetDir); 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) { if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd); const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) { if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
@@ -221,18 +208,6 @@ function resolveProject(cwd = process.cwd(), options = {}) {
} }
} }
if (!repoRoot) { 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 { return {
targetDir, targetDir,
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd, 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) { function isPathInside(candidate, root) {
const rel = path.relative(root, candidate); const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -285,31 +248,10 @@ function resolveEnvContextDir(cwd) {
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed); 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 = {}) { function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null; const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd; if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = resolveTargetPath(cwd, targetPath); const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try { try {
const stat = fs.statSync(abs); const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs); return stat.isDirectory() ? abs : path.dirname(abs);
@@ -1209,19 +1151,13 @@ async function cli() {
throw err; throw err;
} }
const targetProvided = hasTargetOption(cliOptions); const targetProvided = hasTargetOption(cliOptions);
const resolvedTargetPath = targetProvided const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
? resolveTargetPath(process.cwd(), cliOptions.targetPath)
: null;
const targetExists = targetProvided ? fs.existsSync(resolvedTargetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions); const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) { if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n'); process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0); process.exit(0);
} }
const ctx = loadContext( const ctx = loadContext(process.cwd(), cliOptions);
process.cwd(),
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
);
const updateDirective = await computeUpdateDirective(); const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) { if (!ctx.hasProduct) {
@@ -1335,6 +1271,11 @@ function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim()); 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({ const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'], 'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
codex: ['.codex/hooks.json'], codex: ['.codex/hooks.json'],
@@ -1372,39 +1313,6 @@ function hookEnabledAt(root) {
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']); 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) { function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') { if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none'; return 'none';
@@ -1412,10 +1320,8 @@ function automaticHookMode(ctx) {
const activeRoot = path.resolve(ctx.projectRoot || process.cwd()); const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
if (!hookEnabledAt(activeRoot)) return 'none'; if (!hookEnabledAt(activeRoot)) return 'none';
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || []; const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
for (const root of hookManifestSearchRoots(ctx)) { const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
// A manifest can live above the resolved product. Honor the hook lifecycle for (const root of roots) {
// config beside that manifest before treating it as active coverage.
if (!hookEnabledAt(root)) continue;
for (const rel of manifests) { for (const rel of manifests) {
const raw = readJson(path.join(root, rel)); const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) { if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
@@ -18,9 +18,8 @@
* Covers: * Covers:
* - Inline Next.js headers() with CSP string * - Inline Next.js headers() with CSP string
* - Nuxt routeRules / nitro.routeRules CSP headers * - Nuxt routeRules / nitro.routeRules CSP headers
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or * - "middleware": CSP set dynamically in middleware.{ts,js}.
* Next.js 16's proxy.{ts,js,mjs} convention. Detected * Detected but not auto-patched in v1.
* but not auto-patched in v1.
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in * - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
* layout files. Detected but not auto-patched in v1. * layout files. Detected but not auto-patched in v1.
* - null: no CSP signals found; no patch needed. * - null: no CSP signals found; no patch needed.
@@ -78,57 +77,9 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/, /\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 MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\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. * @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }} * @returns {{ shape: string|null, signals: string[] }}
@@ -182,7 +133,8 @@ export function detectCsp(cwd = process.cwd()) {
// === detect-only shapes === // === 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); hits.middleware.push(relPath);
} }
@@ -1228,17 +1228,14 @@ if (IS_BROWSER) {
isHidden: isElementHidden(el), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -1280,381 +1277,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { 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 // Advisory findings are detected but never treated as failures: they list in a
// separate, visually dimmed section, are excluded from the failure count that // separate, visually dimmed section, are excluded from the failure count that
// drives the exit code, and carry `"advisory": true` in JSON so consumers can // 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 // filter. Every advisory finding carries the flag (stamped by the registry via
// findings.mjs). // findings.mjs).
function isAdvisory(finding) { function isAdvisory(finding) {
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory')); return finding && finding.advisory === true;
} }
function partitionAdvisory(findings) { function partitionAdvisory(findings) {
@@ -185,16 +168,6 @@ Advisory findings:
counted as failures and never changing the exit code. They stay out of the 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. 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: Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
@@ -212,7 +185,7 @@ Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS) HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected; URLs Puppeteer full browser rendering (auto-detected;
http(s):// and file:// URLs; accessible linked CSS included) http(s):// and file:// URLs)
Examples: Examples:
impeccable detect src/ impeccable detect src/
@@ -310,16 +283,11 @@ async function detectCli() {
const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache }); const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache });
return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions; 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); } if (helpMode) { printUsage(); process.exit(0); }
let allFindings = []; 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) { if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptionsFor); allFindings = await handleStdin(scanOptionsFor);
@@ -329,23 +297,13 @@ async function detectCli() {
// real cascade, real computed styles, real layout. Callers that want a // real cascade, real computed styles, real layout. Callers that want a
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlRe = /^(?:https?|file):\/\//i;
let browserDetector = null; const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
let browserSetupFailed = false; const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
if (urlTargetCount > 1) {
try {
browserDetector = await createBrowserDetector();
} catch (e) {
browserSetupFailed = true;
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
}
try { try {
for (const target of paths) { for (const target of paths) {
if (URL_TARGET_RE.test(target)) { if (urlRe.test(target)) {
if (browserSetupFailed) continue;
// A file:// URL points at a local artifact, so its design system // 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 // resolves from that file's project. A remote http(s) URL has no
// local project — it gets base options (no design system), never // local project — it gets base options (no design system), never
@@ -358,21 +316,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // 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)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; 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 // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +379,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +413,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); 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 (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +423,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; 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 // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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 classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/); 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) { if (grayMatch && colorBgMatch) {
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` }); 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. // `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index); const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null; if (closeBeforeIndex > open) return null;
// Ignore delimiters inside comments when locating the previous declaration. const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
// 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 raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' '); 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; if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from` // Keyframe steps: percentage steps fail the digit test above, but `from`
@@ -5296,17 +5293,14 @@ function checkTypography() {
} }
if (totalTextElements >= 20) { if (totalTextElements >= 20) {
// Report the actual primary face: the uniquely most-used family. The old // A font is "primary" if it's used by at least 15% of text elements
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split). const PRIMARY_THRESHOLD = 0.15;
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]); for (const [font, count] of fontUsage) {
const [primary] = ranked;
const tied = ranked[1]?.[1] === primary?.[1];
if (primary && !tied) {
const [font, count] = primary;
const share = count / totalTextElements; const share = count / totalTextElements;
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) { if (share < PRIMARY_THRESHOLD) continue;
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` }); 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), isHidden: isElementHidden(el),
findings: findings.map(f => { findings: findings.map(f => {
const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
const severity = f.severity || ap?.severity || 'warning';
return { return {
type: f.type || f.id, type: f.type || f.id,
category: ap ? ap.category : 'quality', category: ap ? ap.category : 'quality',
severity, severity: f.severity || ap?.severity || 'warning',
// Advisory findings (em-dash overuse, etc.) are surfaced but never // Advisory findings (em-dash overuse, etc.) are surfaced but never
// treated as failures; carry the flag so the overlay/extension can // treated as failures; carry the flag so the overlay/extension can
// render them with the mildest affordance and consumers can filter. // render them with the mildest affordance and consumers can filter.
// Per-finding promotions override the registry default, so derive advisory: (ap && ap.advisory === true) || f.advisory === true,
// this strictly from the effective severity.
advisory: severity === 'advisory',
detail: f.detail || f.snippet, detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '', ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id), name: ap ? ap.name : (f.type || f.id),
@@ -8184,381 +8175,6 @@ if (IS_BROWSER) {
else groupMap.set(el, [...kept]); 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) { function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); 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 // (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 // ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level. // ancestor is waived. Selector-less findings stay page-level.
const html = docClone.outerHTML; const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
const corpora = buildHtmlPatternCorpora(html);
const linkedCss = linkedStylesheetText();
if (linkedCss) corpora.styleText += `\n${linkedCss}`;
const scopedHtmlFindings = checkHtmlPatterns(html, corpora).filter(f => {
if (!f.selector) return true; if (!f.selector) return true;
const matches = selectorNodesForLiveDom(document, f.selector); const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!matches) return false; if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false; 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) { if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => { const mapped = scopedHtmlFindings.map(f => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs'; import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
import { checkContentHiddenAtRest } from '../../rules/checks.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) // Per-finding severity promotion (e.g. hero-region pulsing dot)
// overrides the registry default carried by finding(). // overrides the registry default carried by finding().
if (f.severity && f.severity !== item.severity) item.severity = f.severity; 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]); 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 = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { 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)), 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) => { 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] || '?'}`; } },
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] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { 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), 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'; } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs'; import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.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 { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { import {
checkElementBorders, checkElementBorders,
@@ -257,7 +257,7 @@ async function detectHtml(filePath, options = {}) {
// severity (e.g. a pulsing dot inside a header/nav landmark) that // severity (e.g. a pulsing dot inside a header/nav landmark) that
// overrides the registry default. // overrides the registry default.
if (f.severity) item.severity = f.severity; if (f.severity) item.severity = f.severity;
findings.push(deriveAdvisoryFlag(item)); findings.push(item);
} }
// Text-content analyzers (em-dash overuse, marketing buzzwords, // Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex // numbered section markers, aphoristic cadence) live in the regex
@@ -4,12 +4,6 @@ function getAP(id) {
return getAntipattern(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) { function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id); 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 }; 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) // 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 // can partition without a registry lookup. Only stamped when true to keep the
// finding shape stable for the vast majority of rules. // 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, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_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); 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); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -233,7 +233,7 @@ const ANTIPATTERNS = [
// rather than a failure. It fires only on the AI saturation pattern, not on // rather than a failure. It fires only on the AI saturation pattern, not on
// ordinary prose. Advisory findings are surfaced separately, never counted // ordinary prose. Advisory findings are surfaced separately, never counted
// as failures, and skipped by the design hook unless a project opts in. // as failures, and skipped by the design hook unless a project opts in.
severity: 'advisory', advisory: true,
name: 'Em-dash overuse', name: 'Em-dash overuse',
description: 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.', '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: // Advisory rules are detected and reported, but never treated as failures:
// the CLI lists them under a separate "Advisory" section, they do not affect // 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. // exit codes or the failure count, and the design hook skips them by default.
// `severity` is the canonical registry field. The runtime finding serializer // The set is derived from the registry so a rule only needs `advisory: true`.
// derives its `advisory: true` compatibility/output flag from this set.
const ADVISORY_RULE_IDS = new Set( 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) { function isAdvisoryRule(id) {

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