mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
Compare commits
113
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44a25fa241 | ||
|
|
1e19e1838c | ||
|
|
695df68a58 | ||
|
|
3b0f46798a | ||
|
|
524fb8c950 | ||
|
|
f240348cc5 | ||
|
|
f7c92d9eb9 | ||
|
|
6d5f78eebf | ||
|
|
a2cd029548 | ||
|
|
d2750e5198 | ||
|
|
670b1c3fb8 | ||
|
|
26cb1f0193 | ||
|
|
5e626e2d9f | ||
|
|
3a857af9ea | ||
|
|
f32d374ac7 | ||
|
|
689e5150d9 | ||
|
|
80ef6bf06e | ||
|
|
884c9aaf3d | ||
|
|
a91c226b2b | ||
|
|
4c5243fcd4 | ||
|
|
eac24af113 | ||
|
|
fbc5c95355 | ||
|
|
3f815865ab | ||
|
|
3ed4910d5f | ||
|
|
76428b37f1 | ||
|
|
476d43d95f | ||
|
|
85087c20c4 | ||
|
|
89c55d11b6 | ||
|
|
55c2eb3a5d | ||
|
|
4bb4de24ee | ||
|
|
34f514fe16 | ||
|
|
556702efb8 | ||
|
|
dde3067524 | ||
|
|
7d87d6e257 | ||
|
|
1d506359d7 | ||
|
|
5f3defb2da | ||
|
|
819817b705 | ||
|
|
c5adf55bc8 | ||
|
|
88ccc9c42b | ||
|
|
074c6a715d | ||
|
|
5ea8787b02 | ||
|
|
4d6d57b8bd | ||
|
|
50684ececb | ||
|
|
ed50cc5ea0 | ||
|
|
bc45026706 | ||
|
|
c9429e46f6 | ||
|
|
41bda19115 | ||
|
|
8ac3886a9c | ||
|
|
66482a9808 | ||
|
|
0375e219f1 | ||
|
|
c4d001d056 | ||
|
|
c47e1269a0 | ||
|
|
7bf0743852 | ||
|
|
453a6e1500 | ||
|
|
410626e5de | ||
|
|
1d0493af30 | ||
|
|
e7e46104dc | ||
|
|
4369ad538d | ||
|
|
fcc271c1cb | ||
|
|
32b270f4e8 | ||
|
|
5a7e2837d2 | ||
|
|
f2f9958b3d | ||
|
|
1e36c86315 | ||
|
|
8b326fc81e | ||
|
|
0330f61cef | ||
|
|
fa44839f72 | ||
|
|
54f0e641c6 | ||
|
|
672ca29642 | ||
|
|
298194f787 | ||
|
|
36037ea8c5 | ||
|
|
6580c47f1f | ||
|
|
9961848ce2 | ||
|
|
f2c9aeab5b | ||
|
|
836516a7a0 | ||
|
|
3d8b13812f | ||
|
|
0547ed6a63 | ||
|
|
e355ebf714 | ||
|
|
6c474b1c79 | ||
|
|
4a04f6afea | ||
|
|
47f1871385 | ||
|
|
c55cd49895 | ||
|
|
2dccbefefe | ||
|
|
a7a63180f5 | ||
|
|
aa65db332d | ||
|
|
b9902222d9 | ||
|
|
fe30578c8a | ||
|
|
57dd1b75b8 | ||
|
|
3aba4d81c3 | ||
|
|
f4cb04f892 | ||
|
|
aeb8f29a64 | ||
|
|
7394bb41a1 | ||
|
|
b45da84d12 | ||
|
|
fa1dfd0c43 | ||
|
|
65ef0c7fe7 | ||
|
|
77eeecd26b | ||
|
|
166a78ad3d | ||
|
|
4f7fdeca6b | ||
|
|
567b3cfd8c | ||
|
|
27b727a92f | ||
|
|
0071508f70 | ||
|
|
b6a34de55a | ||
|
|
6dd16d21ef | ||
|
|
11a1ea64a6 | ||
|
|
dac3f77d89 | ||
|
|
eb98096ace | ||
|
|
1054202eeb | ||
|
|
d58ef7ab5c | ||
|
|
d1135b9764 | ||
|
|
577dce4323 | ||
|
|
0304059988 | ||
|
|
43435b12ef | ||
|
|
bff0841f52 | ||
|
|
57c1fccf73 |
@@ -200,7 +200,20 @@ 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)) {
|
||||||
@@ -208,6 +221,18 @@ 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,
|
||||||
@@ -223,6 +248,18 @@ 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);
|
||||||
@@ -248,10 +285,31 @@ 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 = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
const abs = resolveTargetPath(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);
|
||||||
@@ -1151,13 +1209,19 @@ async function cli() {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const targetProvided = hasTargetOption(cliOptions);
|
const targetProvided = hasTargetOption(cliOptions);
|
||||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
const resolvedTargetPath = targetProvided
|
||||||
|
? 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(process.cwd(), cliOptions);
|
const ctx = loadContext(
|
||||||
|
process.cwd(),
|
||||||
|
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||||
|
);
|
||||||
const updateDirective = await computeUpdateDirective();
|
const updateDirective = await computeUpdateDirective();
|
||||||
|
|
||||||
if (!ctx.hasProduct) {
|
if (!ctx.hasProduct) {
|
||||||
@@ -1271,11 +1335,6 @@ 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'],
|
||||||
@@ -1313,6 +1372,39 @@ 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';
|
||||||
@@ -1320,8 +1412,10 @@ 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] || [];
|
||||||
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
for (const root of hookManifestSearchRoots(ctx)) {
|
||||||
for (const root of roots) {
|
// A manifest can live above the resolved product. Honor the hook lifecycle
|
||||||
|
// config beside that manifest before treating it as active coverage.
|
||||||
|
if (!hookEnabledAt(root)) continue;
|
||||||
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,8 +18,9 @@
|
|||||||
* 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}.
|
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
|
||||||
* Detected but not auto-patched in v1.
|
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
|
||||||
|
* 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.
|
||||||
@@ -77,9 +78,57 @@ 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[] }}
|
||||||
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
|
|||||||
|
|
||||||
// === detect-only shapes ===
|
// === detect-only shapes ===
|
||||||
|
|
||||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
|
||||||
MIDDLEWARE_HINT.test(body)) {
|
|
||||||
hits.middleware.push(relPath);
|
hits.middleware.push(relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1228,14 +1228,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -1277,6 +1280,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -1650,18 +2028,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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,13 +37,30 @@ 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 finding && finding.advisory === true;
|
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function partitionAdvisory(findings) {
|
function partitionAdvisory(findings) {
|
||||||
@@ -168,6 +185,16 @@ 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,
|
||||||
@@ -185,7 +212,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)
|
http(s):// and file:// URLs; accessible linked CSS included)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
impeccable detect src/
|
impeccable detect src/
|
||||||
@@ -283,11 +310,16 @@ 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 = args.filter(a => !a.startsWith('--'));
|
const targets = expandJoinedUrlTargets(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);
|
||||||
@@ -297,13 +329,23 @@ 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 urlRe = /^(?:https?|file):\/\//i;
|
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
let browserDetector = null;
|
||||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
let browserSetupFailed = false;
|
||||||
|
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 (urlRe.test(target)) {
|
if (URL_TARGET_RE.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
|
||||||
@@ -316,14 +358,21 @@ 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) { process.stderr.write(`Error: ${e.message}\n`); }
|
} catch (e) {
|
||||||
|
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 { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
catch {
|
||||||
|
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)
|
||||||
@@ -352,7 +401,7 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = walkDir(resolved)
|
const files = walkDir(resolved, reportLocalScanFailure)
|
||||||
.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;
|
||||||
|
|
||||||
@@ -368,7 +417,11 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build import graph for multi-file awareness
|
// Build import graph for multi-file awareness
|
||||||
const graph = buildImportGraph(files);
|
const unreadableFiles = new Set();
|
||||||
|
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) {
|
||||||
@@ -379,24 +432,33 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Each file resolves its own project design system (cached by root),
|
if (unreadableFiles.has(file)) continue;
|
||||||
// so a scan spanning sibling projects applies the right rules per file.
|
try {
|
||||||
const fileOptions = scanOptionsFor(file);
|
// Each file resolves its own project design system (cached by root),
|
||||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
// so a scan spanning sibling projects applies the right rules per file.
|
||||||
// Annotate findings with import context
|
const fileOptions = scanOptionsFor(file);
|
||||||
const importers = importedByMap.get(file);
|
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||||
if (importers && importers.size > 0) {
|
// Annotate findings with import context
|
||||||
const importerNames = [...importers].map(f => path.basename(f));
|
const importers = importedByMap.get(file);
|
||||||
for (const f of fileFindings) {
|
if (importers && importers.size > 0) {
|
||||||
f.importedBy = importerNames;
|
const importerNames = [...importers].map(f => path.basename(f));
|
||||||
|
for (const f of fileFindings) {
|
||||||
|
f.importedBy = importerNames;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
allFindings.push(...fileFindings);
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(file, error);
|
||||||
}
|
}
|
||||||
allFindings.push(...fileFindings);
|
|
||||||
}
|
}
|
||||||
} else if (stat.isFile()) {
|
} else if (stat.isFile()) {
|
||||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||||
const fileOptions = scanOptionsFor(resolved);
|
try {
|
||||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
const fileOptions = scanOptionsFor(resolved);
|
||||||
|
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(target, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -413,6 +475,10 @@ 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');
|
||||||
@@ -423,10 +489,10 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||||
process.exit(primary.length > 0 ? 2 : 0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
if (jsonMode) process.stdout.write('[]\n');
|
if (jsonMode) process.stdout.write('[]\n');
|
||||||
process.exit(0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,7 +1961,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -5293,14 +5296,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8126,14 +8132,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -8175,6 +8184,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -8548,18 +8932,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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 item;
|
return deriveAdvisoryFlag(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,147 @@ 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,
|
||||||
@@ -545,8 +686,13 @@ 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) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||||
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] || '?'}`; } },
|
fmt: (m, line) => {
|
||||||
|
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||||
|
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||||
|
.find(Boolean);
|
||||||
|
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||||
|
} },
|
||||||
// --- 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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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(item);
|
findings.push(deriveAdvisoryFlag(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,6 +4,12 @@ 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 };
|
||||||
@@ -11,8 +17,7 @@ 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.
|
||||||
if (ap.advisory === true) base.advisory = true;
|
return deriveAdvisoryFlag(base);
|
||||||
return base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getAP, finding };
|
export { getAP, finding, deriveAdvisoryFlag };
|
||||||
|
|||||||
@@ -46,15 +46,20 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
|||||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
];
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir, onReadError = null) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
try {
|
||||||
|
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));
|
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
@@ -81,12 +86,19 @@ function resolveImport(specifier, fromDir, fileSet) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImportGraph(files) {
|
function buildImportGraph(files, onReadError = null) {
|
||||||
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) {
|
||||||
const content = fs.readFileSync(file, 'utf-8');
|
let content;
|
||||||
|
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,9 +588,10 @@ 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.
|
||||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
// `severity` is the canonical registry field. The runtime finding serializer
|
||||||
|
// 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.advisory === true).map(rule => rule.id),
|
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').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,7 +688,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -4020,14 +4023,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,17 +138,20 @@ 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 set is the hook's own copy of the registry's `advisory: true` rules,
|
// This legacy id fallback keeps older detector findings recognizable when they
|
||||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
// carry neither the current runtime flag nor the canonical advisory severity.
|
||||||
// self-contained and testable without loading the detector. Keep it in sync
|
// Current findings are classified by their serialized metadata below.
|
||||||
// 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 && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
return Boolean(id && (
|
||||||
|
ADVISORY_RULES.has(id)
|
||||||
|
|| finding.advisory === true
|
||||||
|
|| finding.severity === 'advisory'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG = Object.freeze({
|
export const DEFAULT_CONFIG = Object.freeze({
|
||||||
|
|||||||
@@ -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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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,6 +5820,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLiveState('CYCLING');
|
setLiveState('CYCLING');
|
||||||
|
hideShaderOverlay();
|
||||||
showOrUpdateCyclingBar();
|
showOrUpdateCyclingBar();
|
||||||
saveSession();
|
saveSession();
|
||||||
completeParameterGenerationIfReady();
|
completeParameterGenerationIfReady();
|
||||||
@@ -6216,6 +6217,71 @@
|
|||||||
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) {
|
||||||
@@ -6296,7 +6362,7 @@
|
|||||||
}
|
}
|
||||||
rememberSessionFileMeta({ file: filePath });
|
rememberSessionFileMeta({ file: filePath });
|
||||||
if (isJsxSourceFile(filePath)) {
|
if (isJsxSourceFile(filePath)) {
|
||||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const liveWrapper = findVariantsWrapper(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;
|
||||||
@@ -6326,14 +6392,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||||
const attempt = opts._orphanAttempt || 0;
|
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -6375,7 +6434,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const existingWrapper = findVariantsWrapper(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);
|
||||||
@@ -6532,7 +6591,7 @@
|
|||||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -6542,7 +6601,7 @@
|
|||||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||||
return svelteComponentSession.mountedVariant;
|
return svelteComponentSession.mountedVariant;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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) {
|
||||||
@@ -6657,8 +6716,17 @@
|
|||||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
/**
|
||||||
removeDiscardStateStylesheet(sessionId);
|
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||||
|
* one wrapper per item, so the hide, the release, and the existence checks
|
||||||
|
* all have to speak about the same set.
|
||||||
|
*/
|
||||||
|
function discardedWrappers(sessionId) {
|
||||||
|
if (!sessionId) return [];
|
||||||
|
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseDiscardedStaticWrapper(wrapper) {
|
||||||
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;
|
||||||
@@ -6669,6 +6737,18 @@
|
|||||||
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;
|
||||||
@@ -6849,6 +6929,42 @@
|
|||||||
// 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
|
||||||
|
|
||||||
@@ -6878,7 +6994,7 @@
|
|||||||
}
|
}
|
||||||
if (!dominated) return;
|
if (!dominated) return;
|
||||||
|
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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"])');
|
||||||
@@ -7087,6 +7203,7 @@
|
|||||||
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();
|
||||||
@@ -7147,6 +7264,7 @@
|
|||||||
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;
|
||||||
@@ -8249,6 +8367,15 @@ 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
|
||||||
@@ -8395,14 +8522,28 @@ 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() {
|
||||||
if (!shaderState) return;
|
// Bump first, unconditionally: this is what tells an in-flight
|
||||||
|
// showShaderOverlay to abandon itself rather than publish over a session
|
||||||
|
// that has already moved on.
|
||||||
|
shaderEpoch += 1;
|
||||||
|
if (!shaderState) {
|
||||||
|
removeStrayShaderNode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shaderState.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) {
|
||||||
@@ -8427,6 +8568,16 @@ 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);
|
||||||
@@ -8449,6 +8600,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -8488,16 +8640,22 @@ 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);
|
||||||
@@ -8516,6 +8674,7 @@ 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;
|
||||||
@@ -8552,7 +8711,7 @@ void main() {
|
|||||||
clientSentAt: Date.now(),
|
clientSentAt: Date.now(),
|
||||||
};
|
};
|
||||||
if (!currentSessionId || arrivedVariants === 0) return;
|
if (!currentSessionId || arrivedVariants === 0) return;
|
||||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||||
}
|
}
|
||||||
@@ -8595,6 +8754,7 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -8646,7 +8806,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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 {
|
||||||
@@ -8773,7 +8933,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -9001,7 +9161,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreFromActiveSessions(activeSessions, reason) {
|
function restoreFromActiveSessions(activeSessions, reason) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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);
|
||||||
@@ -9114,10 +9274,13 @@ 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).
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
// Every match, not the first: a target inside a `.map()` renders one
|
||||||
if (wrapper) {
|
// wrapper per item, and hiding only one leaves the rest of the
|
||||||
|
// discarded variants on screen.
|
||||||
|
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||||
|
if (discardWrappers.length > 0) {
|
||||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||||
else wrapper.style.display = 'none';
|
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||||
}
|
}
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||||
@@ -9125,16 +9288,19 @@ void main() {
|
|||||||
removeDiscardStateStylesheet();
|
removeDiscardStateStylesheet();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (!lateWrapper) {
|
if (lateWrappers.length === 0) {
|
||||||
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 {
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9143,18 +9309,20 @@ 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 staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeDiscardStateStylesheet(cleanupSessionId);
|
removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
if (staleWrapper) location.reload();
|
// A reload restores every wrapper's original at once, so there is
|
||||||
|
// nothing per-wrapper to do here.
|
||||||
|
if (staleWrappers.length > 0) location.reload();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
hideBar(instantChrome);
|
hideBar(instantChrome);
|
||||||
@@ -9342,8 +9510,13 @@ void main() {
|
|||||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
// Which path resumed matters in the journal: an init resume is a fresh
|
||||||
|
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||||
|
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||||
|
// DOM reconstruction to diagnose.
|
||||||
|
const resumeReason = opts.reason || 'browser_resumed';
|
||||||
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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;
|
||||||
@@ -9442,16 +9615,38 @@ void main() {
|
|||||||
|
|
||||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||||
startScrollTracking();
|
startScrollTracking();
|
||||||
// Build the params panel for the restored visible variant. Previously
|
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||||
// hid. Now that state is CYCLING, re-fire.
|
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||||
if (state === 'CYCLING') refreshParamsPanel();
|
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||||
|
// transition the observer would have finished. Without hideShaderOverlay
|
||||||
|
// the generating shader stays frozen over the target and the session looks
|
||||||
|
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||||
|
if (state === 'CYCLING') {
|
||||||
|
recoveryWaitingForAnchor = false;
|
||||||
|
hideShaderOverlay();
|
||||||
|
if (isInsert) finalizeInsertSession();
|
||||||
|
disableInlineEdit();
|
||||||
|
// Build the params panel for the restored visible variant. Previously
|
||||||
|
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||||
|
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||||
|
// hid. Now that state is CYCLING, re-fire.
|
||||||
|
refreshParamsPanel();
|
||||||
|
}
|
||||||
saveSession();
|
saveSession();
|
||||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||||
sendCheckpoint('variants_progress');
|
sendCheckpoint('variants_progress');
|
||||||
} else {
|
} else {
|
||||||
queueCheckpoint('browser_resumed');
|
queueCheckpoint(resumeReason);
|
||||||
|
// Only variants_progress and variants_ready count as publication
|
||||||
|
// progress. When the resume is the arrival, the observer never gets to
|
||||||
|
// report it (this function disconnects and re-creates it below, which
|
||||||
|
// drops the records it had already queued for this same batch), so
|
||||||
|
// without this the server never learns the variants were published.
|
||||||
|
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||||
|
sendCheckpoint('variants_ready');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start observing for more variants AFTER initial setup
|
// Start observing for more variants AFTER initial setup
|
||||||
@@ -12773,7 +12968,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)) {
|
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[alias]
|
||||||
|
xtask = "run --quiet --package xtask --"
|
||||||
@@ -200,7 +200,20 @@ 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)) {
|
||||||
@@ -208,6 +221,18 @@ 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,
|
||||||
@@ -223,6 +248,18 @@ 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);
|
||||||
@@ -248,10 +285,31 @@ 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 = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
const abs = resolveTargetPath(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);
|
||||||
@@ -1151,13 +1209,19 @@ async function cli() {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const targetProvided = hasTargetOption(cliOptions);
|
const targetProvided = hasTargetOption(cliOptions);
|
||||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
const resolvedTargetPath = targetProvided
|
||||||
|
? 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(process.cwd(), cliOptions);
|
const ctx = loadContext(
|
||||||
|
process.cwd(),
|
||||||
|
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||||
|
);
|
||||||
const updateDirective = await computeUpdateDirective();
|
const updateDirective = await computeUpdateDirective();
|
||||||
|
|
||||||
if (!ctx.hasProduct) {
|
if (!ctx.hasProduct) {
|
||||||
@@ -1271,11 +1335,6 @@ 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'],
|
||||||
@@ -1313,6 +1372,39 @@ 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';
|
||||||
@@ -1320,8 +1412,10 @@ 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] || [];
|
||||||
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
for (const root of hookManifestSearchRoots(ctx)) {
|
||||||
for (const root of roots) {
|
// A manifest can live above the resolved product. Honor the hook lifecycle
|
||||||
|
// config beside that manifest before treating it as active coverage.
|
||||||
|
if (!hookEnabledAt(root)) continue;
|
||||||
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,8 +18,9 @@
|
|||||||
* 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}.
|
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
|
||||||
* Detected but not auto-patched in v1.
|
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
|
||||||
|
* 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.
|
||||||
@@ -77,9 +78,57 @@ 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[] }}
|
||||||
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
|
|||||||
|
|
||||||
// === detect-only shapes ===
|
// === detect-only shapes ===
|
||||||
|
|
||||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
|
||||||
MIDDLEWARE_HINT.test(body)) {
|
|
||||||
hits.middleware.push(relPath);
|
hits.middleware.push(relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1228,14 +1228,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -1277,6 +1280,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -1650,18 +2028,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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,13 +37,30 @@ 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 finding && finding.advisory === true;
|
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function partitionAdvisory(findings) {
|
function partitionAdvisory(findings) {
|
||||||
@@ -168,6 +185,16 @@ 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,
|
||||||
@@ -185,7 +212,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)
|
http(s):// and file:// URLs; accessible linked CSS included)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
impeccable detect src/
|
impeccable detect src/
|
||||||
@@ -283,11 +310,16 @@ 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 = args.filter(a => !a.startsWith('--'));
|
const targets = expandJoinedUrlTargets(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);
|
||||||
@@ -297,13 +329,23 @@ 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 urlRe = /^(?:https?|file):\/\//i;
|
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
let browserDetector = null;
|
||||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
let browserSetupFailed = false;
|
||||||
|
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 (urlRe.test(target)) {
|
if (URL_TARGET_RE.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
|
||||||
@@ -316,14 +358,21 @@ 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) { process.stderr.write(`Error: ${e.message}\n`); }
|
} catch (e) {
|
||||||
|
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 { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
catch {
|
||||||
|
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)
|
||||||
@@ -352,7 +401,7 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = walkDir(resolved)
|
const files = walkDir(resolved, reportLocalScanFailure)
|
||||||
.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;
|
||||||
|
|
||||||
@@ -368,7 +417,11 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build import graph for multi-file awareness
|
// Build import graph for multi-file awareness
|
||||||
const graph = buildImportGraph(files);
|
const unreadableFiles = new Set();
|
||||||
|
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) {
|
||||||
@@ -379,24 +432,33 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Each file resolves its own project design system (cached by root),
|
if (unreadableFiles.has(file)) continue;
|
||||||
// so a scan spanning sibling projects applies the right rules per file.
|
try {
|
||||||
const fileOptions = scanOptionsFor(file);
|
// Each file resolves its own project design system (cached by root),
|
||||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
// so a scan spanning sibling projects applies the right rules per file.
|
||||||
// Annotate findings with import context
|
const fileOptions = scanOptionsFor(file);
|
||||||
const importers = importedByMap.get(file);
|
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||||
if (importers && importers.size > 0) {
|
// Annotate findings with import context
|
||||||
const importerNames = [...importers].map(f => path.basename(f));
|
const importers = importedByMap.get(file);
|
||||||
for (const f of fileFindings) {
|
if (importers && importers.size > 0) {
|
||||||
f.importedBy = importerNames;
|
const importerNames = [...importers].map(f => path.basename(f));
|
||||||
|
for (const f of fileFindings) {
|
||||||
|
f.importedBy = importerNames;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
allFindings.push(...fileFindings);
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(file, error);
|
||||||
}
|
}
|
||||||
allFindings.push(...fileFindings);
|
|
||||||
}
|
}
|
||||||
} else if (stat.isFile()) {
|
} else if (stat.isFile()) {
|
||||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||||
const fileOptions = scanOptionsFor(resolved);
|
try {
|
||||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
const fileOptions = scanOptionsFor(resolved);
|
||||||
|
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(target, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -413,6 +475,10 @@ 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');
|
||||||
@@ -423,10 +489,10 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||||
process.exit(primary.length > 0 ? 2 : 0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
if (jsonMode) process.stdout.write('[]\n');
|
if (jsonMode) process.stdout.write('[]\n');
|
||||||
process.exit(0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,7 +1961,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -5293,14 +5296,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8126,14 +8132,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -8175,6 +8184,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -8548,18 +8932,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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 item;
|
return deriveAdvisoryFlag(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,147 @@ 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,
|
||||||
@@ -545,8 +686,13 @@ 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) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||||
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] || '?'}`; } },
|
fmt: (m, line) => {
|
||||||
|
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||||
|
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||||
|
.find(Boolean);
|
||||||
|
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||||
|
} },
|
||||||
// --- 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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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(item);
|
findings.push(deriveAdvisoryFlag(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,6 +4,12 @@ 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 };
|
||||||
@@ -11,8 +17,7 @@ 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.
|
||||||
if (ap.advisory === true) base.advisory = true;
|
return deriveAdvisoryFlag(base);
|
||||||
return base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getAP, finding };
|
export { getAP, finding, deriveAdvisoryFlag };
|
||||||
|
|||||||
@@ -46,15 +46,20 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
|||||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
];
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir, onReadError = null) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
try {
|
||||||
|
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));
|
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
@@ -81,12 +86,19 @@ function resolveImport(specifier, fromDir, fileSet) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImportGraph(files) {
|
function buildImportGraph(files, onReadError = null) {
|
||||||
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) {
|
||||||
const content = fs.readFileSync(file, 'utf-8');
|
let content;
|
||||||
|
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,9 +588,10 @@ 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.
|
||||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
// `severity` is the canonical registry field. The runtime finding serializer
|
||||||
|
// 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.advisory === true).map(rule => rule.id),
|
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').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,7 +688,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -4020,14 +4023,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,17 +138,20 @@ 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 set is the hook's own copy of the registry's `advisory: true` rules,
|
// This legacy id fallback keeps older detector findings recognizable when they
|
||||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
// carry neither the current runtime flag nor the canonical advisory severity.
|
||||||
// self-contained and testable without loading the detector. Keep it in sync
|
// Current findings are classified by their serialized metadata below.
|
||||||
// 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 && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
return Boolean(id && (
|
||||||
|
ADVISORY_RULES.has(id)
|
||||||
|
|| finding.advisory === true
|
||||||
|
|| finding.severity === 'advisory'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG = Object.freeze({
|
export const DEFAULT_CONFIG = Object.freeze({
|
||||||
|
|||||||
@@ -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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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,6 +5820,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLiveState('CYCLING');
|
setLiveState('CYCLING');
|
||||||
|
hideShaderOverlay();
|
||||||
showOrUpdateCyclingBar();
|
showOrUpdateCyclingBar();
|
||||||
saveSession();
|
saveSession();
|
||||||
completeParameterGenerationIfReady();
|
completeParameterGenerationIfReady();
|
||||||
@@ -6216,6 +6217,71 @@
|
|||||||
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) {
|
||||||
@@ -6296,7 +6362,7 @@
|
|||||||
}
|
}
|
||||||
rememberSessionFileMeta({ file: filePath });
|
rememberSessionFileMeta({ file: filePath });
|
||||||
if (isJsxSourceFile(filePath)) {
|
if (isJsxSourceFile(filePath)) {
|
||||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const liveWrapper = findVariantsWrapper(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;
|
||||||
@@ -6326,14 +6392,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||||
const attempt = opts._orphanAttempt || 0;
|
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -6375,7 +6434,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const existingWrapper = findVariantsWrapper(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);
|
||||||
@@ -6532,7 +6591,7 @@
|
|||||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -6542,7 +6601,7 @@
|
|||||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||||
return svelteComponentSession.mountedVariant;
|
return svelteComponentSession.mountedVariant;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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) {
|
||||||
@@ -6657,8 +6716,17 @@
|
|||||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
/**
|
||||||
removeDiscardStateStylesheet(sessionId);
|
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||||
|
* one wrapper per item, so the hide, the release, and the existence checks
|
||||||
|
* all have to speak about the same set.
|
||||||
|
*/
|
||||||
|
function discardedWrappers(sessionId) {
|
||||||
|
if (!sessionId) return [];
|
||||||
|
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseDiscardedStaticWrapper(wrapper) {
|
||||||
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;
|
||||||
@@ -6669,6 +6737,18 @@
|
|||||||
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;
|
||||||
@@ -6849,6 +6929,42 @@
|
|||||||
// 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
|
||||||
|
|
||||||
@@ -6878,7 +6994,7 @@
|
|||||||
}
|
}
|
||||||
if (!dominated) return;
|
if (!dominated) return;
|
||||||
|
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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"])');
|
||||||
@@ -7087,6 +7203,7 @@
|
|||||||
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();
|
||||||
@@ -7147,6 +7264,7 @@
|
|||||||
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;
|
||||||
@@ -8249,6 +8367,15 @@ 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
|
||||||
@@ -8395,14 +8522,28 @@ 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() {
|
||||||
if (!shaderState) return;
|
// Bump first, unconditionally: this is what tells an in-flight
|
||||||
|
// showShaderOverlay to abandon itself rather than publish over a session
|
||||||
|
// that has already moved on.
|
||||||
|
shaderEpoch += 1;
|
||||||
|
if (!shaderState) {
|
||||||
|
removeStrayShaderNode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shaderState.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) {
|
||||||
@@ -8427,6 +8568,16 @@ 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);
|
||||||
@@ -8449,6 +8600,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -8488,16 +8640,22 @@ 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);
|
||||||
@@ -8516,6 +8674,7 @@ 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;
|
||||||
@@ -8552,7 +8711,7 @@ void main() {
|
|||||||
clientSentAt: Date.now(),
|
clientSentAt: Date.now(),
|
||||||
};
|
};
|
||||||
if (!currentSessionId || arrivedVariants === 0) return;
|
if (!currentSessionId || arrivedVariants === 0) return;
|
||||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||||
}
|
}
|
||||||
@@ -8595,6 +8754,7 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -8646,7 +8806,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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 {
|
||||||
@@ -8773,7 +8933,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -9001,7 +9161,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreFromActiveSessions(activeSessions, reason) {
|
function restoreFromActiveSessions(activeSessions, reason) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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);
|
||||||
@@ -9114,10 +9274,13 @@ 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).
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
// Every match, not the first: a target inside a `.map()` renders one
|
||||||
if (wrapper) {
|
// wrapper per item, and hiding only one leaves the rest of the
|
||||||
|
// discarded variants on screen.
|
||||||
|
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||||
|
if (discardWrappers.length > 0) {
|
||||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||||
else wrapper.style.display = 'none';
|
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||||
}
|
}
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||||
@@ -9125,16 +9288,19 @@ void main() {
|
|||||||
removeDiscardStateStylesheet();
|
removeDiscardStateStylesheet();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (!lateWrapper) {
|
if (lateWrappers.length === 0) {
|
||||||
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 {
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9143,18 +9309,20 @@ 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 staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeDiscardStateStylesheet(cleanupSessionId);
|
removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
if (staleWrapper) location.reload();
|
// A reload restores every wrapper's original at once, so there is
|
||||||
|
// nothing per-wrapper to do here.
|
||||||
|
if (staleWrappers.length > 0) location.reload();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
hideBar(instantChrome);
|
hideBar(instantChrome);
|
||||||
@@ -9342,8 +9510,13 @@ void main() {
|
|||||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
// Which path resumed matters in the journal: an init resume is a fresh
|
||||||
|
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||||
|
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||||
|
// DOM reconstruction to diagnose.
|
||||||
|
const resumeReason = opts.reason || 'browser_resumed';
|
||||||
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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;
|
||||||
@@ -9442,16 +9615,38 @@ void main() {
|
|||||||
|
|
||||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||||
startScrollTracking();
|
startScrollTracking();
|
||||||
// Build the params panel for the restored visible variant. Previously
|
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||||
// hid. Now that state is CYCLING, re-fire.
|
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||||
if (state === 'CYCLING') refreshParamsPanel();
|
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||||
|
// transition the observer would have finished. Without hideShaderOverlay
|
||||||
|
// the generating shader stays frozen over the target and the session looks
|
||||||
|
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||||
|
if (state === 'CYCLING') {
|
||||||
|
recoveryWaitingForAnchor = false;
|
||||||
|
hideShaderOverlay();
|
||||||
|
if (isInsert) finalizeInsertSession();
|
||||||
|
disableInlineEdit();
|
||||||
|
// Build the params panel for the restored visible variant. Previously
|
||||||
|
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||||
|
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||||
|
// hid. Now that state is CYCLING, re-fire.
|
||||||
|
refreshParamsPanel();
|
||||||
|
}
|
||||||
saveSession();
|
saveSession();
|
||||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||||
sendCheckpoint('variants_progress');
|
sendCheckpoint('variants_progress');
|
||||||
} else {
|
} else {
|
||||||
queueCheckpoint('browser_resumed');
|
queueCheckpoint(resumeReason);
|
||||||
|
// Only variants_progress and variants_ready count as publication
|
||||||
|
// progress. When the resume is the arrival, the observer never gets to
|
||||||
|
// report it (this function disconnects and re-creates it below, which
|
||||||
|
// drops the records it had already queued for this same batch), so
|
||||||
|
// without this the server never learns the variants were published.
|
||||||
|
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||||
|
sendCheckpoint('variants_ready');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start observing for more variants AFTER initial setup
|
// Start observing for more variants AFTER initial setup
|
||||||
@@ -12773,7 +12968,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)) {
|
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -200,7 +200,20 @@ 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)) {
|
||||||
@@ -208,6 +221,18 @@ 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,
|
||||||
@@ -223,6 +248,18 @@ 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);
|
||||||
@@ -248,10 +285,31 @@ 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 = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
const abs = resolveTargetPath(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);
|
||||||
@@ -1151,13 +1209,19 @@ async function cli() {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const targetProvided = hasTargetOption(cliOptions);
|
const targetProvided = hasTargetOption(cliOptions);
|
||||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
const resolvedTargetPath = targetProvided
|
||||||
|
? 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(process.cwd(), cliOptions);
|
const ctx = loadContext(
|
||||||
|
process.cwd(),
|
||||||
|
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||||
|
);
|
||||||
const updateDirective = await computeUpdateDirective();
|
const updateDirective = await computeUpdateDirective();
|
||||||
|
|
||||||
if (!ctx.hasProduct) {
|
if (!ctx.hasProduct) {
|
||||||
@@ -1271,11 +1335,6 @@ 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'],
|
||||||
@@ -1313,6 +1372,39 @@ 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';
|
||||||
@@ -1320,8 +1412,10 @@ 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] || [];
|
||||||
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
for (const root of hookManifestSearchRoots(ctx)) {
|
||||||
for (const root of roots) {
|
// A manifest can live above the resolved product. Honor the hook lifecycle
|
||||||
|
// config beside that manifest before treating it as active coverage.
|
||||||
|
if (!hookEnabledAt(root)) continue;
|
||||||
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,8 +18,9 @@
|
|||||||
* 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}.
|
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
|
||||||
* Detected but not auto-patched in v1.
|
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
|
||||||
|
* 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.
|
||||||
@@ -77,9 +78,57 @@ 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[] }}
|
||||||
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
|
|||||||
|
|
||||||
// === detect-only shapes ===
|
// === detect-only shapes ===
|
||||||
|
|
||||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
|
||||||
MIDDLEWARE_HINT.test(body)) {
|
|
||||||
hits.middleware.push(relPath);
|
hits.middleware.push(relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1228,14 +1228,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -1277,6 +1280,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -1650,18 +2028,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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,13 +37,30 @@ 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 finding && finding.advisory === true;
|
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function partitionAdvisory(findings) {
|
function partitionAdvisory(findings) {
|
||||||
@@ -168,6 +185,16 @@ 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,
|
||||||
@@ -185,7 +212,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)
|
http(s):// and file:// URLs; accessible linked CSS included)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
impeccable detect src/
|
impeccable detect src/
|
||||||
@@ -283,11 +310,16 @@ 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 = args.filter(a => !a.startsWith('--'));
|
const targets = expandJoinedUrlTargets(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);
|
||||||
@@ -297,13 +329,23 @@ 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 urlRe = /^(?:https?|file):\/\//i;
|
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
let browserDetector = null;
|
||||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
let browserSetupFailed = false;
|
||||||
|
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 (urlRe.test(target)) {
|
if (URL_TARGET_RE.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
|
||||||
@@ -316,14 +358,21 @@ 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) { process.stderr.write(`Error: ${e.message}\n`); }
|
} catch (e) {
|
||||||
|
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 { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
catch {
|
||||||
|
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)
|
||||||
@@ -352,7 +401,7 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = walkDir(resolved)
|
const files = walkDir(resolved, reportLocalScanFailure)
|
||||||
.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;
|
||||||
|
|
||||||
@@ -368,7 +417,11 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build import graph for multi-file awareness
|
// Build import graph for multi-file awareness
|
||||||
const graph = buildImportGraph(files);
|
const unreadableFiles = new Set();
|
||||||
|
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) {
|
||||||
@@ -379,24 +432,33 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Each file resolves its own project design system (cached by root),
|
if (unreadableFiles.has(file)) continue;
|
||||||
// so a scan spanning sibling projects applies the right rules per file.
|
try {
|
||||||
const fileOptions = scanOptionsFor(file);
|
// Each file resolves its own project design system (cached by root),
|
||||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
// so a scan spanning sibling projects applies the right rules per file.
|
||||||
// Annotate findings with import context
|
const fileOptions = scanOptionsFor(file);
|
||||||
const importers = importedByMap.get(file);
|
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||||
if (importers && importers.size > 0) {
|
// Annotate findings with import context
|
||||||
const importerNames = [...importers].map(f => path.basename(f));
|
const importers = importedByMap.get(file);
|
||||||
for (const f of fileFindings) {
|
if (importers && importers.size > 0) {
|
||||||
f.importedBy = importerNames;
|
const importerNames = [...importers].map(f => path.basename(f));
|
||||||
|
for (const f of fileFindings) {
|
||||||
|
f.importedBy = importerNames;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
allFindings.push(...fileFindings);
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(file, error);
|
||||||
}
|
}
|
||||||
allFindings.push(...fileFindings);
|
|
||||||
}
|
}
|
||||||
} else if (stat.isFile()) {
|
} else if (stat.isFile()) {
|
||||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||||
const fileOptions = scanOptionsFor(resolved);
|
try {
|
||||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
const fileOptions = scanOptionsFor(resolved);
|
||||||
|
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(target, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -413,6 +475,10 @@ 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');
|
||||||
@@ -423,10 +489,10 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||||
process.exit(primary.length > 0 ? 2 : 0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
if (jsonMode) process.stdout.write('[]\n');
|
if (jsonMode) process.stdout.write('[]\n');
|
||||||
process.exit(0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,7 +1961,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -5293,14 +5296,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8126,14 +8132,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -8175,6 +8184,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -8548,18 +8932,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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 item;
|
return deriveAdvisoryFlag(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,147 @@ 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,
|
||||||
@@ -545,8 +686,13 @@ 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) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||||
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] || '?'}`; } },
|
fmt: (m, line) => {
|
||||||
|
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||||
|
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||||
|
.find(Boolean);
|
||||||
|
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||||
|
} },
|
||||||
// --- 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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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(item);
|
findings.push(deriveAdvisoryFlag(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,6 +4,12 @@ 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 };
|
||||||
@@ -11,8 +17,7 @@ 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.
|
||||||
if (ap.advisory === true) base.advisory = true;
|
return deriveAdvisoryFlag(base);
|
||||||
return base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getAP, finding };
|
export { getAP, finding, deriveAdvisoryFlag };
|
||||||
|
|||||||
@@ -46,15 +46,20 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
|||||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
];
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir, onReadError = null) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
try {
|
||||||
|
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));
|
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
@@ -81,12 +86,19 @@ function resolveImport(specifier, fromDir, fileSet) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImportGraph(files) {
|
function buildImportGraph(files, onReadError = null) {
|
||||||
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) {
|
||||||
const content = fs.readFileSync(file, 'utf-8');
|
let content;
|
||||||
|
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,9 +588,10 @@ 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.
|
||||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
// `severity` is the canonical registry field. The runtime finding serializer
|
||||||
|
// 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.advisory === true).map(rule => rule.id),
|
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').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,7 +688,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -4020,14 +4023,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,17 +138,20 @@ 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 set is the hook's own copy of the registry's `advisory: true` rules,
|
// This legacy id fallback keeps older detector findings recognizable when they
|
||||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
// carry neither the current runtime flag nor the canonical advisory severity.
|
||||||
// self-contained and testable without loading the detector. Keep it in sync
|
// Current findings are classified by their serialized metadata below.
|
||||||
// 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 && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
return Boolean(id && (
|
||||||
|
ADVISORY_RULES.has(id)
|
||||||
|
|| finding.advisory === true
|
||||||
|
|| finding.severity === 'advisory'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG = Object.freeze({
|
export const DEFAULT_CONFIG = Object.freeze({
|
||||||
|
|||||||
@@ -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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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,6 +5820,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLiveState('CYCLING');
|
setLiveState('CYCLING');
|
||||||
|
hideShaderOverlay();
|
||||||
showOrUpdateCyclingBar();
|
showOrUpdateCyclingBar();
|
||||||
saveSession();
|
saveSession();
|
||||||
completeParameterGenerationIfReady();
|
completeParameterGenerationIfReady();
|
||||||
@@ -6216,6 +6217,71 @@
|
|||||||
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) {
|
||||||
@@ -6296,7 +6362,7 @@
|
|||||||
}
|
}
|
||||||
rememberSessionFileMeta({ file: filePath });
|
rememberSessionFileMeta({ file: filePath });
|
||||||
if (isJsxSourceFile(filePath)) {
|
if (isJsxSourceFile(filePath)) {
|
||||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const liveWrapper = findVariantsWrapper(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;
|
||||||
@@ -6326,14 +6392,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||||
const attempt = opts._orphanAttempt || 0;
|
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -6375,7 +6434,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const existingWrapper = findVariantsWrapper(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);
|
||||||
@@ -6532,7 +6591,7 @@
|
|||||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -6542,7 +6601,7 @@
|
|||||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||||
return svelteComponentSession.mountedVariant;
|
return svelteComponentSession.mountedVariant;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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) {
|
||||||
@@ -6657,8 +6716,17 @@
|
|||||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
/**
|
||||||
removeDiscardStateStylesheet(sessionId);
|
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||||
|
* one wrapper per item, so the hide, the release, and the existence checks
|
||||||
|
* all have to speak about the same set.
|
||||||
|
*/
|
||||||
|
function discardedWrappers(sessionId) {
|
||||||
|
if (!sessionId) return [];
|
||||||
|
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseDiscardedStaticWrapper(wrapper) {
|
||||||
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;
|
||||||
@@ -6669,6 +6737,18 @@
|
|||||||
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;
|
||||||
@@ -6849,6 +6929,42 @@
|
|||||||
// 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
|
||||||
|
|
||||||
@@ -6878,7 +6994,7 @@
|
|||||||
}
|
}
|
||||||
if (!dominated) return;
|
if (!dominated) return;
|
||||||
|
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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"])');
|
||||||
@@ -7087,6 +7203,7 @@
|
|||||||
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();
|
||||||
@@ -7147,6 +7264,7 @@
|
|||||||
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;
|
||||||
@@ -8249,6 +8367,15 @@ 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
|
||||||
@@ -8395,14 +8522,28 @@ 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() {
|
||||||
if (!shaderState) return;
|
// Bump first, unconditionally: this is what tells an in-flight
|
||||||
|
// showShaderOverlay to abandon itself rather than publish over a session
|
||||||
|
// that has already moved on.
|
||||||
|
shaderEpoch += 1;
|
||||||
|
if (!shaderState) {
|
||||||
|
removeStrayShaderNode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shaderState.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) {
|
||||||
@@ -8427,6 +8568,16 @@ 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);
|
||||||
@@ -8449,6 +8600,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -8488,16 +8640,22 @@ 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);
|
||||||
@@ -8516,6 +8674,7 @@ 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;
|
||||||
@@ -8552,7 +8711,7 @@ void main() {
|
|||||||
clientSentAt: Date.now(),
|
clientSentAt: Date.now(),
|
||||||
};
|
};
|
||||||
if (!currentSessionId || arrivedVariants === 0) return;
|
if (!currentSessionId || arrivedVariants === 0) return;
|
||||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||||
}
|
}
|
||||||
@@ -8595,6 +8754,7 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -8646,7 +8806,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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 {
|
||||||
@@ -8773,7 +8933,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -9001,7 +9161,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreFromActiveSessions(activeSessions, reason) {
|
function restoreFromActiveSessions(activeSessions, reason) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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);
|
||||||
@@ -9114,10 +9274,13 @@ 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).
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
// Every match, not the first: a target inside a `.map()` renders one
|
||||||
if (wrapper) {
|
// wrapper per item, and hiding only one leaves the rest of the
|
||||||
|
// discarded variants on screen.
|
||||||
|
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||||
|
if (discardWrappers.length > 0) {
|
||||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||||
else wrapper.style.display = 'none';
|
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||||
}
|
}
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||||
@@ -9125,16 +9288,19 @@ void main() {
|
|||||||
removeDiscardStateStylesheet();
|
removeDiscardStateStylesheet();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (!lateWrapper) {
|
if (lateWrappers.length === 0) {
|
||||||
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 {
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9143,18 +9309,20 @@ 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 staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeDiscardStateStylesheet(cleanupSessionId);
|
removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
if (staleWrapper) location.reload();
|
// A reload restores every wrapper's original at once, so there is
|
||||||
|
// nothing per-wrapper to do here.
|
||||||
|
if (staleWrappers.length > 0) location.reload();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
hideBar(instantChrome);
|
hideBar(instantChrome);
|
||||||
@@ -9342,8 +9510,13 @@ void main() {
|
|||||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
// Which path resumed matters in the journal: an init resume is a fresh
|
||||||
|
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||||
|
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||||
|
// DOM reconstruction to diagnose.
|
||||||
|
const resumeReason = opts.reason || 'browser_resumed';
|
||||||
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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;
|
||||||
@@ -9442,16 +9615,38 @@ void main() {
|
|||||||
|
|
||||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||||
startScrollTracking();
|
startScrollTracking();
|
||||||
// Build the params panel for the restored visible variant. Previously
|
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||||
// hid. Now that state is CYCLING, re-fire.
|
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||||
if (state === 'CYCLING') refreshParamsPanel();
|
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||||
|
// transition the observer would have finished. Without hideShaderOverlay
|
||||||
|
// the generating shader stays frozen over the target and the session looks
|
||||||
|
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||||
|
if (state === 'CYCLING') {
|
||||||
|
recoveryWaitingForAnchor = false;
|
||||||
|
hideShaderOverlay();
|
||||||
|
if (isInsert) finalizeInsertSession();
|
||||||
|
disableInlineEdit();
|
||||||
|
// Build the params panel for the restored visible variant. Previously
|
||||||
|
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||||
|
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||||
|
// hid. Now that state is CYCLING, re-fire.
|
||||||
|
refreshParamsPanel();
|
||||||
|
}
|
||||||
saveSession();
|
saveSession();
|
||||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||||
sendCheckpoint('variants_progress');
|
sendCheckpoint('variants_progress');
|
||||||
} else {
|
} else {
|
||||||
queueCheckpoint('browser_resumed');
|
queueCheckpoint(resumeReason);
|
||||||
|
// Only variants_progress and variants_ready count as publication
|
||||||
|
// progress. When the resume is the arrival, the observer never gets to
|
||||||
|
// report it (this function disconnects and re-creates it below, which
|
||||||
|
// drops the records it had already queued for this same batch), so
|
||||||
|
// without this the server never learns the variants were published.
|
||||||
|
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||||
|
sendCheckpoint('variants_ready');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start observing for more variants AFTER initial setup
|
// Start observing for more variants AFTER initial setup
|
||||||
@@ -12773,7 +12968,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)) {
|
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -200,7 +200,20 @@ 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)) {
|
||||||
@@ -208,6 +221,18 @@ 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,
|
||||||
@@ -223,6 +248,18 @@ 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);
|
||||||
@@ -248,10 +285,31 @@ 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 = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
const abs = resolveTargetPath(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);
|
||||||
@@ -1151,13 +1209,19 @@ async function cli() {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const targetProvided = hasTargetOption(cliOptions);
|
const targetProvided = hasTargetOption(cliOptions);
|
||||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
const resolvedTargetPath = targetProvided
|
||||||
|
? 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(process.cwd(), cliOptions);
|
const ctx = loadContext(
|
||||||
|
process.cwd(),
|
||||||
|
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||||
|
);
|
||||||
const updateDirective = await computeUpdateDirective();
|
const updateDirective = await computeUpdateDirective();
|
||||||
|
|
||||||
if (!ctx.hasProduct) {
|
if (!ctx.hasProduct) {
|
||||||
@@ -1271,11 +1335,6 @@ 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'],
|
||||||
@@ -1313,6 +1372,39 @@ 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';
|
||||||
@@ -1320,8 +1412,10 @@ 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] || [];
|
||||||
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
for (const root of hookManifestSearchRoots(ctx)) {
|
||||||
for (const root of roots) {
|
// A manifest can live above the resolved product. Honor the hook lifecycle
|
||||||
|
// config beside that manifest before treating it as active coverage.
|
||||||
|
if (!hookEnabledAt(root)) continue;
|
||||||
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,8 +18,9 @@
|
|||||||
* 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}.
|
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
|
||||||
* Detected but not auto-patched in v1.
|
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
|
||||||
|
* 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.
|
||||||
@@ -77,9 +78,57 @@ 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[] }}
|
||||||
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
|
|||||||
|
|
||||||
// === detect-only shapes ===
|
// === detect-only shapes ===
|
||||||
|
|
||||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
|
||||||
MIDDLEWARE_HINT.test(body)) {
|
|
||||||
hits.middleware.push(relPath);
|
hits.middleware.push(relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1228,14 +1228,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -1277,6 +1280,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -1650,18 +2028,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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,13 +37,30 @@ 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 finding && finding.advisory === true;
|
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function partitionAdvisory(findings) {
|
function partitionAdvisory(findings) {
|
||||||
@@ -168,6 +185,16 @@ 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,
|
||||||
@@ -185,7 +212,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)
|
http(s):// and file:// URLs; accessible linked CSS included)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
impeccable detect src/
|
impeccable detect src/
|
||||||
@@ -283,11 +310,16 @@ 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 = args.filter(a => !a.startsWith('--'));
|
const targets = expandJoinedUrlTargets(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);
|
||||||
@@ -297,13 +329,23 @@ 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 urlRe = /^(?:https?|file):\/\//i;
|
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
let browserDetector = null;
|
||||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
let browserSetupFailed = false;
|
||||||
|
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 (urlRe.test(target)) {
|
if (URL_TARGET_RE.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
|
||||||
@@ -316,14 +358,21 @@ 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) { process.stderr.write(`Error: ${e.message}\n`); }
|
} catch (e) {
|
||||||
|
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 { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
catch {
|
||||||
|
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)
|
||||||
@@ -352,7 +401,7 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = walkDir(resolved)
|
const files = walkDir(resolved, reportLocalScanFailure)
|
||||||
.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;
|
||||||
|
|
||||||
@@ -368,7 +417,11 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build import graph for multi-file awareness
|
// Build import graph for multi-file awareness
|
||||||
const graph = buildImportGraph(files);
|
const unreadableFiles = new Set();
|
||||||
|
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) {
|
||||||
@@ -379,24 +432,33 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Each file resolves its own project design system (cached by root),
|
if (unreadableFiles.has(file)) continue;
|
||||||
// so a scan spanning sibling projects applies the right rules per file.
|
try {
|
||||||
const fileOptions = scanOptionsFor(file);
|
// Each file resolves its own project design system (cached by root),
|
||||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
// so a scan spanning sibling projects applies the right rules per file.
|
||||||
// Annotate findings with import context
|
const fileOptions = scanOptionsFor(file);
|
||||||
const importers = importedByMap.get(file);
|
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||||
if (importers && importers.size > 0) {
|
// Annotate findings with import context
|
||||||
const importerNames = [...importers].map(f => path.basename(f));
|
const importers = importedByMap.get(file);
|
||||||
for (const f of fileFindings) {
|
if (importers && importers.size > 0) {
|
||||||
f.importedBy = importerNames;
|
const importerNames = [...importers].map(f => path.basename(f));
|
||||||
|
for (const f of fileFindings) {
|
||||||
|
f.importedBy = importerNames;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
allFindings.push(...fileFindings);
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(file, error);
|
||||||
}
|
}
|
||||||
allFindings.push(...fileFindings);
|
|
||||||
}
|
}
|
||||||
} else if (stat.isFile()) {
|
} else if (stat.isFile()) {
|
||||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||||
const fileOptions = scanOptionsFor(resolved);
|
try {
|
||||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
const fileOptions = scanOptionsFor(resolved);
|
||||||
|
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(target, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -413,6 +475,10 @@ 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');
|
||||||
@@ -423,10 +489,10 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||||
process.exit(primary.length > 0 ? 2 : 0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
if (jsonMode) process.stdout.write('[]\n');
|
if (jsonMode) process.stdout.write('[]\n');
|
||||||
process.exit(0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,7 +1961,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -5293,14 +5296,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8126,14 +8132,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -8175,6 +8184,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -8548,18 +8932,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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 item;
|
return deriveAdvisoryFlag(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,147 @@ 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,
|
||||||
@@ -545,8 +686,13 @@ 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) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||||
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] || '?'}`; } },
|
fmt: (m, line) => {
|
||||||
|
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||||
|
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||||
|
.find(Boolean);
|
||||||
|
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||||
|
} },
|
||||||
// --- 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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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(item);
|
findings.push(deriveAdvisoryFlag(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,6 +4,12 @@ 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 };
|
||||||
@@ -11,8 +17,7 @@ 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.
|
||||||
if (ap.advisory === true) base.advisory = true;
|
return deriveAdvisoryFlag(base);
|
||||||
return base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getAP, finding };
|
export { getAP, finding, deriveAdvisoryFlag };
|
||||||
|
|||||||
@@ -46,15 +46,20 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
|||||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
];
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir, onReadError = null) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
try {
|
||||||
|
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));
|
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
@@ -81,12 +86,19 @@ function resolveImport(specifier, fromDir, fileSet) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImportGraph(files) {
|
function buildImportGraph(files, onReadError = null) {
|
||||||
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) {
|
||||||
const content = fs.readFileSync(file, 'utf-8');
|
let content;
|
||||||
|
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,9 +588,10 @@ 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.
|
||||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
// `severity` is the canonical registry field. The runtime finding serializer
|
||||||
|
// 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.advisory === true).map(rule => rule.id),
|
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').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,7 +688,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -4020,14 +4023,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,17 +138,20 @@ 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 set is the hook's own copy of the registry's `advisory: true` rules,
|
// This legacy id fallback keeps older detector findings recognizable when they
|
||||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
// carry neither the current runtime flag nor the canonical advisory severity.
|
||||||
// self-contained and testable without loading the detector. Keep it in sync
|
// Current findings are classified by their serialized metadata below.
|
||||||
// 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 && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
return Boolean(id && (
|
||||||
|
ADVISORY_RULES.has(id)
|
||||||
|
|| finding.advisory === true
|
||||||
|
|| finding.severity === 'advisory'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG = Object.freeze({
|
export const DEFAULT_CONFIG = Object.freeze({
|
||||||
|
|||||||
@@ -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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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,6 +5820,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLiveState('CYCLING');
|
setLiveState('CYCLING');
|
||||||
|
hideShaderOverlay();
|
||||||
showOrUpdateCyclingBar();
|
showOrUpdateCyclingBar();
|
||||||
saveSession();
|
saveSession();
|
||||||
completeParameterGenerationIfReady();
|
completeParameterGenerationIfReady();
|
||||||
@@ -6216,6 +6217,71 @@
|
|||||||
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) {
|
||||||
@@ -6296,7 +6362,7 @@
|
|||||||
}
|
}
|
||||||
rememberSessionFileMeta({ file: filePath });
|
rememberSessionFileMeta({ file: filePath });
|
||||||
if (isJsxSourceFile(filePath)) {
|
if (isJsxSourceFile(filePath)) {
|
||||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const liveWrapper = findVariantsWrapper(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;
|
||||||
@@ -6326,14 +6392,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||||
const attempt = opts._orphanAttempt || 0;
|
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -6375,7 +6434,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const existingWrapper = findVariantsWrapper(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);
|
||||||
@@ -6532,7 +6591,7 @@
|
|||||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -6542,7 +6601,7 @@
|
|||||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||||
return svelteComponentSession.mountedVariant;
|
return svelteComponentSession.mountedVariant;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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) {
|
||||||
@@ -6657,8 +6716,17 @@
|
|||||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
/**
|
||||||
removeDiscardStateStylesheet(sessionId);
|
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||||
|
* one wrapper per item, so the hide, the release, and the existence checks
|
||||||
|
* all have to speak about the same set.
|
||||||
|
*/
|
||||||
|
function discardedWrappers(sessionId) {
|
||||||
|
if (!sessionId) return [];
|
||||||
|
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseDiscardedStaticWrapper(wrapper) {
|
||||||
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;
|
||||||
@@ -6669,6 +6737,18 @@
|
|||||||
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;
|
||||||
@@ -6849,6 +6929,42 @@
|
|||||||
// 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
|
||||||
|
|
||||||
@@ -6878,7 +6994,7 @@
|
|||||||
}
|
}
|
||||||
if (!dominated) return;
|
if (!dominated) return;
|
||||||
|
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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"])');
|
||||||
@@ -7087,6 +7203,7 @@
|
|||||||
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();
|
||||||
@@ -7147,6 +7264,7 @@
|
|||||||
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;
|
||||||
@@ -8249,6 +8367,15 @@ 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
|
||||||
@@ -8395,14 +8522,28 @@ 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() {
|
||||||
if (!shaderState) return;
|
// Bump first, unconditionally: this is what tells an in-flight
|
||||||
|
// showShaderOverlay to abandon itself rather than publish over a session
|
||||||
|
// that has already moved on.
|
||||||
|
shaderEpoch += 1;
|
||||||
|
if (!shaderState) {
|
||||||
|
removeStrayShaderNode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shaderState.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) {
|
||||||
@@ -8427,6 +8568,16 @@ 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);
|
||||||
@@ -8449,6 +8600,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -8488,16 +8640,22 @@ 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);
|
||||||
@@ -8516,6 +8674,7 @@ 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;
|
||||||
@@ -8552,7 +8711,7 @@ void main() {
|
|||||||
clientSentAt: Date.now(),
|
clientSentAt: Date.now(),
|
||||||
};
|
};
|
||||||
if (!currentSessionId || arrivedVariants === 0) return;
|
if (!currentSessionId || arrivedVariants === 0) return;
|
||||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||||
}
|
}
|
||||||
@@ -8595,6 +8754,7 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -8646,7 +8806,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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 {
|
||||||
@@ -8773,7 +8933,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -9001,7 +9161,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreFromActiveSessions(activeSessions, reason) {
|
function restoreFromActiveSessions(activeSessions, reason) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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);
|
||||||
@@ -9114,10 +9274,13 @@ 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).
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
// Every match, not the first: a target inside a `.map()` renders one
|
||||||
if (wrapper) {
|
// wrapper per item, and hiding only one leaves the rest of the
|
||||||
|
// discarded variants on screen.
|
||||||
|
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||||
|
if (discardWrappers.length > 0) {
|
||||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||||
else wrapper.style.display = 'none';
|
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||||
}
|
}
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||||
@@ -9125,16 +9288,19 @@ void main() {
|
|||||||
removeDiscardStateStylesheet();
|
removeDiscardStateStylesheet();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (!lateWrapper) {
|
if (lateWrappers.length === 0) {
|
||||||
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 {
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9143,18 +9309,20 @@ 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 staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeDiscardStateStylesheet(cleanupSessionId);
|
removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
if (staleWrapper) location.reload();
|
// A reload restores every wrapper's original at once, so there is
|
||||||
|
// nothing per-wrapper to do here.
|
||||||
|
if (staleWrappers.length > 0) location.reload();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
hideBar(instantChrome);
|
hideBar(instantChrome);
|
||||||
@@ -9342,8 +9510,13 @@ void main() {
|
|||||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
// Which path resumed matters in the journal: an init resume is a fresh
|
||||||
|
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||||
|
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||||
|
// DOM reconstruction to diagnose.
|
||||||
|
const resumeReason = opts.reason || 'browser_resumed';
|
||||||
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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;
|
||||||
@@ -9442,16 +9615,38 @@ void main() {
|
|||||||
|
|
||||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||||
startScrollTracking();
|
startScrollTracking();
|
||||||
// Build the params panel for the restored visible variant. Previously
|
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||||
// hid. Now that state is CYCLING, re-fire.
|
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||||
if (state === 'CYCLING') refreshParamsPanel();
|
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||||
|
// transition the observer would have finished. Without hideShaderOverlay
|
||||||
|
// the generating shader stays frozen over the target and the session looks
|
||||||
|
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||||
|
if (state === 'CYCLING') {
|
||||||
|
recoveryWaitingForAnchor = false;
|
||||||
|
hideShaderOverlay();
|
||||||
|
if (isInsert) finalizeInsertSession();
|
||||||
|
disableInlineEdit();
|
||||||
|
// Build the params panel for the restored visible variant. Previously
|
||||||
|
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||||
|
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||||
|
// hid. Now that state is CYCLING, re-fire.
|
||||||
|
refreshParamsPanel();
|
||||||
|
}
|
||||||
saveSession();
|
saveSession();
|
||||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||||
sendCheckpoint('variants_progress');
|
sendCheckpoint('variants_progress');
|
||||||
} else {
|
} else {
|
||||||
queueCheckpoint('browser_resumed');
|
queueCheckpoint(resumeReason);
|
||||||
|
// Only variants_progress and variants_ready count as publication
|
||||||
|
// progress. When the resume is the arrival, the observer never gets to
|
||||||
|
// report it (this function disconnects and re-creates it below, which
|
||||||
|
// drops the records it had already queued for this same batch), so
|
||||||
|
// without this the server never learns the variants were published.
|
||||||
|
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||||
|
sendCheckpoint('variants_ready');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start observing for more variants AFTER initial setup
|
// Start observing for more variants AFTER initial setup
|
||||||
@@ -12773,7 +12968,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)) {
|
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 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
|
||||||
@@ -200,7 +200,20 @@ 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)) {
|
||||||
@@ -208,6 +221,18 @@ 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,
|
||||||
@@ -223,6 +248,18 @@ 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);
|
||||||
@@ -248,10 +285,31 @@ 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 = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
const abs = resolveTargetPath(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);
|
||||||
@@ -1151,13 +1209,19 @@ async function cli() {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const targetProvided = hasTargetOption(cliOptions);
|
const targetProvided = hasTargetOption(cliOptions);
|
||||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
const resolvedTargetPath = targetProvided
|
||||||
|
? 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(process.cwd(), cliOptions);
|
const ctx = loadContext(
|
||||||
|
process.cwd(),
|
||||||
|
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||||
|
);
|
||||||
const updateDirective = await computeUpdateDirective();
|
const updateDirective = await computeUpdateDirective();
|
||||||
|
|
||||||
if (!ctx.hasProduct) {
|
if (!ctx.hasProduct) {
|
||||||
@@ -1271,11 +1335,6 @@ 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'],
|
||||||
@@ -1313,6 +1372,39 @@ 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';
|
||||||
@@ -1320,8 +1412,10 @@ 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] || [];
|
||||||
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
for (const root of hookManifestSearchRoots(ctx)) {
|
||||||
for (const root of roots) {
|
// A manifest can live above the resolved product. Honor the hook lifecycle
|
||||||
|
// config beside that manifest before treating it as active coverage.
|
||||||
|
if (!hookEnabledAt(root)) continue;
|
||||||
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,8 +18,9 @@
|
|||||||
* 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}.
|
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
|
||||||
* Detected but not auto-patched in v1.
|
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
|
||||||
|
* 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.
|
||||||
@@ -77,9 +78,57 @@ 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[] }}
|
||||||
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
|
|||||||
|
|
||||||
// === detect-only shapes ===
|
// === detect-only shapes ===
|
||||||
|
|
||||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
|
||||||
MIDDLEWARE_HINT.test(body)) {
|
|
||||||
hits.middleware.push(relPath);
|
hits.middleware.push(relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1228,14 +1228,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -1277,6 +1280,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -1650,18 +2028,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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,13 +37,30 @@ 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 finding && finding.advisory === true;
|
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function partitionAdvisory(findings) {
|
function partitionAdvisory(findings) {
|
||||||
@@ -168,6 +185,16 @@ 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,
|
||||||
@@ -185,7 +212,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)
|
http(s):// and file:// URLs; accessible linked CSS included)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
impeccable detect src/
|
impeccable detect src/
|
||||||
@@ -283,11 +310,16 @@ 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 = args.filter(a => !a.startsWith('--'));
|
const targets = expandJoinedUrlTargets(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);
|
||||||
@@ -297,13 +329,23 @@ 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 urlRe = /^(?:https?|file):\/\//i;
|
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
let browserDetector = null;
|
||||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
let browserSetupFailed = false;
|
||||||
|
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 (urlRe.test(target)) {
|
if (URL_TARGET_RE.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
|
||||||
@@ -316,14 +358,21 @@ 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) { process.stderr.write(`Error: ${e.message}\n`); }
|
} catch (e) {
|
||||||
|
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 { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
catch {
|
||||||
|
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)
|
||||||
@@ -352,7 +401,7 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = walkDir(resolved)
|
const files = walkDir(resolved, reportLocalScanFailure)
|
||||||
.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;
|
||||||
|
|
||||||
@@ -368,7 +417,11 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build import graph for multi-file awareness
|
// Build import graph for multi-file awareness
|
||||||
const graph = buildImportGraph(files);
|
const unreadableFiles = new Set();
|
||||||
|
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) {
|
||||||
@@ -379,24 +432,33 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Each file resolves its own project design system (cached by root),
|
if (unreadableFiles.has(file)) continue;
|
||||||
// so a scan spanning sibling projects applies the right rules per file.
|
try {
|
||||||
const fileOptions = scanOptionsFor(file);
|
// Each file resolves its own project design system (cached by root),
|
||||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
// so a scan spanning sibling projects applies the right rules per file.
|
||||||
// Annotate findings with import context
|
const fileOptions = scanOptionsFor(file);
|
||||||
const importers = importedByMap.get(file);
|
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||||
if (importers && importers.size > 0) {
|
// Annotate findings with import context
|
||||||
const importerNames = [...importers].map(f => path.basename(f));
|
const importers = importedByMap.get(file);
|
||||||
for (const f of fileFindings) {
|
if (importers && importers.size > 0) {
|
||||||
f.importedBy = importerNames;
|
const importerNames = [...importers].map(f => path.basename(f));
|
||||||
|
for (const f of fileFindings) {
|
||||||
|
f.importedBy = importerNames;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
allFindings.push(...fileFindings);
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(file, error);
|
||||||
}
|
}
|
||||||
allFindings.push(...fileFindings);
|
|
||||||
}
|
}
|
||||||
} else if (stat.isFile()) {
|
} else if (stat.isFile()) {
|
||||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||||
const fileOptions = scanOptionsFor(resolved);
|
try {
|
||||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
const fileOptions = scanOptionsFor(resolved);
|
||||||
|
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(target, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -413,6 +475,10 @@ 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');
|
||||||
@@ -423,10 +489,10 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||||
process.exit(primary.length > 0 ? 2 : 0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
if (jsonMode) process.stdout.write('[]\n');
|
if (jsonMode) process.stdout.write('[]\n');
|
||||||
process.exit(0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,7 +1961,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -5293,14 +5296,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8126,14 +8132,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -8175,6 +8184,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -8548,18 +8932,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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 item;
|
return deriveAdvisoryFlag(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,147 @@ 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,
|
||||||
@@ -545,8 +686,13 @@ 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) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||||
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] || '?'}`; } },
|
fmt: (m, line) => {
|
||||||
|
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||||
|
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||||
|
.find(Boolean);
|
||||||
|
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||||
|
} },
|
||||||
// --- 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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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(item);
|
findings.push(deriveAdvisoryFlag(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,6 +4,12 @@ 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 };
|
||||||
@@ -11,8 +17,7 @@ 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.
|
||||||
if (ap.advisory === true) base.advisory = true;
|
return deriveAdvisoryFlag(base);
|
||||||
return base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getAP, finding };
|
export { getAP, finding, deriveAdvisoryFlag };
|
||||||
|
|||||||
@@ -46,15 +46,20 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
|||||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
];
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir, onReadError = null) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
try {
|
||||||
|
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));
|
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
@@ -81,12 +86,19 @@ function resolveImport(specifier, fromDir, fileSet) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImportGraph(files) {
|
function buildImportGraph(files, onReadError = null) {
|
||||||
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) {
|
||||||
const content = fs.readFileSync(file, 'utf-8');
|
let content;
|
||||||
|
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,9 +588,10 @@ 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.
|
||||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
// `severity` is the canonical registry field. The runtime finding serializer
|
||||||
|
// 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.advisory === true).map(rule => rule.id),
|
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').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,7 +688,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -4020,14 +4023,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,17 +138,20 @@ 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 set is the hook's own copy of the registry's `advisory: true` rules,
|
// This legacy id fallback keeps older detector findings recognizable when they
|
||||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
// carry neither the current runtime flag nor the canonical advisory severity.
|
||||||
// self-contained and testable without loading the detector. Keep it in sync
|
// Current findings are classified by their serialized metadata below.
|
||||||
// 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 && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
return Boolean(id && (
|
||||||
|
ADVISORY_RULES.has(id)
|
||||||
|
|| finding.advisory === true
|
||||||
|
|| finding.severity === 'advisory'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG = Object.freeze({
|
export const DEFAULT_CONFIG = Object.freeze({
|
||||||
|
|||||||
@@ -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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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,6 +5820,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLiveState('CYCLING');
|
setLiveState('CYCLING');
|
||||||
|
hideShaderOverlay();
|
||||||
showOrUpdateCyclingBar();
|
showOrUpdateCyclingBar();
|
||||||
saveSession();
|
saveSession();
|
||||||
completeParameterGenerationIfReady();
|
completeParameterGenerationIfReady();
|
||||||
@@ -6216,6 +6217,71 @@
|
|||||||
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) {
|
||||||
@@ -6296,7 +6362,7 @@
|
|||||||
}
|
}
|
||||||
rememberSessionFileMeta({ file: filePath });
|
rememberSessionFileMeta({ file: filePath });
|
||||||
if (isJsxSourceFile(filePath)) {
|
if (isJsxSourceFile(filePath)) {
|
||||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const liveWrapper = findVariantsWrapper(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;
|
||||||
@@ -6326,14 +6392,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||||
const attempt = opts._orphanAttempt || 0;
|
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -6375,7 +6434,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const existingWrapper = findVariantsWrapper(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);
|
||||||
@@ -6532,7 +6591,7 @@
|
|||||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -6542,7 +6601,7 @@
|
|||||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||||
return svelteComponentSession.mountedVariant;
|
return svelteComponentSession.mountedVariant;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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) {
|
||||||
@@ -6657,8 +6716,17 @@
|
|||||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
/**
|
||||||
removeDiscardStateStylesheet(sessionId);
|
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||||
|
* one wrapper per item, so the hide, the release, and the existence checks
|
||||||
|
* all have to speak about the same set.
|
||||||
|
*/
|
||||||
|
function discardedWrappers(sessionId) {
|
||||||
|
if (!sessionId) return [];
|
||||||
|
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseDiscardedStaticWrapper(wrapper) {
|
||||||
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;
|
||||||
@@ -6669,6 +6737,18 @@
|
|||||||
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;
|
||||||
@@ -6849,6 +6929,42 @@
|
|||||||
// 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
|
||||||
|
|
||||||
@@ -6878,7 +6994,7 @@
|
|||||||
}
|
}
|
||||||
if (!dominated) return;
|
if (!dominated) return;
|
||||||
|
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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"])');
|
||||||
@@ -7087,6 +7203,7 @@
|
|||||||
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();
|
||||||
@@ -7147,6 +7264,7 @@
|
|||||||
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;
|
||||||
@@ -8249,6 +8367,15 @@ 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
|
||||||
@@ -8395,14 +8522,28 @@ 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() {
|
||||||
if (!shaderState) return;
|
// Bump first, unconditionally: this is what tells an in-flight
|
||||||
|
// showShaderOverlay to abandon itself rather than publish over a session
|
||||||
|
// that has already moved on.
|
||||||
|
shaderEpoch += 1;
|
||||||
|
if (!shaderState) {
|
||||||
|
removeStrayShaderNode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shaderState.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) {
|
||||||
@@ -8427,6 +8568,16 @@ 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);
|
||||||
@@ -8449,6 +8600,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -8488,16 +8640,22 @@ 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);
|
||||||
@@ -8516,6 +8674,7 @@ 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;
|
||||||
@@ -8552,7 +8711,7 @@ void main() {
|
|||||||
clientSentAt: Date.now(),
|
clientSentAt: Date.now(),
|
||||||
};
|
};
|
||||||
if (!currentSessionId || arrivedVariants === 0) return;
|
if (!currentSessionId || arrivedVariants === 0) return;
|
||||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||||
}
|
}
|
||||||
@@ -8595,6 +8754,7 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -8646,7 +8806,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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 {
|
||||||
@@ -8773,7 +8933,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -9001,7 +9161,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreFromActiveSessions(activeSessions, reason) {
|
function restoreFromActiveSessions(activeSessions, reason) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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);
|
||||||
@@ -9114,10 +9274,13 @@ 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).
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
// Every match, not the first: a target inside a `.map()` renders one
|
||||||
if (wrapper) {
|
// wrapper per item, and hiding only one leaves the rest of the
|
||||||
|
// discarded variants on screen.
|
||||||
|
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||||
|
if (discardWrappers.length > 0) {
|
||||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||||
else wrapper.style.display = 'none';
|
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||||
}
|
}
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||||
@@ -9125,16 +9288,19 @@ void main() {
|
|||||||
removeDiscardStateStylesheet();
|
removeDiscardStateStylesheet();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (!lateWrapper) {
|
if (lateWrappers.length === 0) {
|
||||||
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 {
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9143,18 +9309,20 @@ 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 staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeDiscardStateStylesheet(cleanupSessionId);
|
removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
if (staleWrapper) location.reload();
|
// A reload restores every wrapper's original at once, so there is
|
||||||
|
// nothing per-wrapper to do here.
|
||||||
|
if (staleWrappers.length > 0) location.reload();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
hideBar(instantChrome);
|
hideBar(instantChrome);
|
||||||
@@ -9342,8 +9510,13 @@ void main() {
|
|||||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
// Which path resumed matters in the journal: an init resume is a fresh
|
||||||
|
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||||
|
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||||
|
// DOM reconstruction to diagnose.
|
||||||
|
const resumeReason = opts.reason || 'browser_resumed';
|
||||||
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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;
|
||||||
@@ -9442,16 +9615,38 @@ void main() {
|
|||||||
|
|
||||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||||
startScrollTracking();
|
startScrollTracking();
|
||||||
// Build the params panel for the restored visible variant. Previously
|
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||||
// hid. Now that state is CYCLING, re-fire.
|
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||||
if (state === 'CYCLING') refreshParamsPanel();
|
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||||
|
// transition the observer would have finished. Without hideShaderOverlay
|
||||||
|
// the generating shader stays frozen over the target and the session looks
|
||||||
|
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||||
|
if (state === 'CYCLING') {
|
||||||
|
recoveryWaitingForAnchor = false;
|
||||||
|
hideShaderOverlay();
|
||||||
|
if (isInsert) finalizeInsertSession();
|
||||||
|
disableInlineEdit();
|
||||||
|
// Build the params panel for the restored visible variant. Previously
|
||||||
|
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||||
|
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||||
|
// hid. Now that state is CYCLING, re-fire.
|
||||||
|
refreshParamsPanel();
|
||||||
|
}
|
||||||
saveSession();
|
saveSession();
|
||||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||||
sendCheckpoint('variants_progress');
|
sendCheckpoint('variants_progress');
|
||||||
} else {
|
} else {
|
||||||
queueCheckpoint('browser_resumed');
|
queueCheckpoint(resumeReason);
|
||||||
|
// Only variants_progress and variants_ready count as publication
|
||||||
|
// progress. When the resume is the arrival, the observer never gets to
|
||||||
|
// report it (this function disconnects and re-creates it below, which
|
||||||
|
// drops the records it had already queued for this same batch), so
|
||||||
|
// without this the server never learns the variants were published.
|
||||||
|
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||||
|
sendCheckpoint('variants_ready');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start observing for more variants AFTER initial setup
|
// Start observing for more variants AFTER initial setup
|
||||||
@@ -12773,7 +12968,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)) {
|
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+185
-6
@@ -23,6 +23,7 @@ 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 }}
|
||||||
@@ -92,13 +93,22 @@ 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
|
||||||
@@ -111,18 +121,131 @@ 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
|
||||||
run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin cli/engine/detect-antipatterns-browser.js extension/detector
|
# extension/detector/ is gitignored (built by `cargo xtask bundle`);
|
||||||
|
# it stays listed so a stray tracked copy shows up here.
|
||||||
|
run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin extension/detector
|
||||||
|
|
||||||
- name: Upload build artifacts
|
- name: Upload build artifacts
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: impeccable-dist-node-${{ matrix.node-version }}
|
name: impeccable-build-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
|
||||||
@@ -157,6 +280,16 @@ 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
|
||||||
|
|
||||||
@@ -212,6 +345,16 @@ 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:
|
||||||
@@ -288,6 +431,16 @@ 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:
|
||||||
@@ -360,6 +513,19 @@ 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: |
|
||||||
@@ -424,6 +590,19 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: release-engine
|
||||||
|
# Builds the engine binary for every supported target and publishes them, with
|
||||||
|
# sha256 sidecars, as the GitHub Release `engine-v<X>` on this repo. That
|
||||||
|
# release is what the launcher (skill/scripts/impeccable), the npm shim
|
||||||
|
# (cli/bin/cli.js), `impeccable install`, and `bun run fetch:engine` download.
|
||||||
|
#
|
||||||
|
# Trigger: `bun run release:engine` (scripts/release.mjs) verifies
|
||||||
|
# ENGINE_VERSION, the npm platform-package pins, and a clean tree, then
|
||||||
|
# pushes the tag. Third-party actions are pinned to commit SHAs so a
|
||||||
|
# moved tag cannot swap the code this workflow runs.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['engine-v*']
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- { os: macos-14, target: aarch64-apple-darwin, short: darwin-arm64 }
|
||||||
|
# No Intel runner: GitHub retired macos-13. Apple's toolchain builds
|
||||||
|
# x86_64 on an arm64 host natively once the target is installed.
|
||||||
|
- { os: macos-14, target: x86_64-apple-darwin, short: darwin-x64 }
|
||||||
|
- { os: ubuntu-latest, target: x86_64-unknown-linux-musl, short: linux-x64 }
|
||||||
|
- { os: ubuntu-latest, target: aarch64-unknown-linux-musl, short: linux-arm64, cross: true }
|
||||||
|
- { os: windows-latest, target: x86_64-pc-windows-msvc, short: windows-x64 }
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
|
- name: Check the tag matches ENGINE_VERSION
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
want="engine-v$(tr -d '[:space:]' < ENGINE_VERSION)"
|
||||||
|
[ "$GITHUB_REF_NAME" = "$want" ] || { echo "tag $GITHUB_REF_NAME != $want"; exit 1; }
|
||||||
|
# rust-toolchain.toml names the channel; `rustup show` installs it.
|
||||||
|
# Never override the toolchain here.
|
||||||
|
- name: Install the pinned toolchain
|
||||||
|
shell: bash
|
||||||
|
run: rustup show && rustup target add ${{ matrix.target }}
|
||||||
|
- if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||||
|
- if: matrix.cross
|
||||||
|
run: cargo install cross --locked
|
||||||
|
- name: Build
|
||||||
|
shell: bash
|
||||||
|
run: ${{ matrix.cross && 'cross' || 'cargo' }} build --release -p impeccable --target ${{ matrix.target }}
|
||||||
|
- name: Smoke the binary
|
||||||
|
if: ${{ !matrix.cross }}
|
||||||
|
shell: bash
|
||||||
|
run: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }} engine-probe
|
||||||
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: impeccable-${{ matrix.short }}
|
||||||
|
path: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }}
|
||||||
|
if-no-files-found: error
|
||||||
|
publish:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
|
with: { path: artifacts }
|
||||||
|
- name: Lay out release assets with checksums
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mkdir -p out
|
||||||
|
for d in artifacts/impeccable-*; do
|
||||||
|
short=$(basename "$d" | sed 's/^impeccable-//')
|
||||||
|
f=$(ls "$d" | head -1)
|
||||||
|
case "$short" in windows-*) dest="out/impeccable-$short.exe" ;; *) dest="out/impeccable-$short" ;; esac
|
||||||
|
cp "$d/$f" "$dest"
|
||||||
|
(cd out && sha256sum "$(basename "$dest")" > "$(basename "$dest").sha256")
|
||||||
|
done
|
||||||
|
ls -la out
|
||||||
|
- name: Publish the GitHub Release
|
||||||
|
env: { GH_TOKEN: "${{ github.token }}" }
|
||||||
|
# No --clobber: a published asset is immutable. A re-run against an
|
||||||
|
# existing release fails on the first existing asset instead of
|
||||||
|
# silently replacing a binary and its sidecar hash.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "impeccable engine $tag" \
|
||||||
|
--notes "Prebuilt impeccable engine binaries ($tag). The launcher, the npm shim and impeccable install download these on first run. Docs: https://impeccable.style" out/* || \
|
||||||
|
gh release upload "$tag" out/* --repo "$GITHUB_REPOSITORY"
|
||||||
+12
@@ -13,10 +13,17 @@ 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
|
||||||
@@ -83,6 +90,11 @@ 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 —
|
||||||
|
|||||||
@@ -200,7 +200,20 @@ 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)) {
|
||||||
@@ -208,6 +221,18 @@ 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,
|
||||||
@@ -223,6 +248,18 @@ 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);
|
||||||
@@ -248,10 +285,31 @@ 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 = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
const abs = resolveTargetPath(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);
|
||||||
@@ -1151,13 +1209,19 @@ async function cli() {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const targetProvided = hasTargetOption(cliOptions);
|
const targetProvided = hasTargetOption(cliOptions);
|
||||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
const resolvedTargetPath = targetProvided
|
||||||
|
? 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(process.cwd(), cliOptions);
|
const ctx = loadContext(
|
||||||
|
process.cwd(),
|
||||||
|
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||||
|
);
|
||||||
const updateDirective = await computeUpdateDirective();
|
const updateDirective = await computeUpdateDirective();
|
||||||
|
|
||||||
if (!ctx.hasProduct) {
|
if (!ctx.hasProduct) {
|
||||||
@@ -1271,11 +1335,6 @@ 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'],
|
||||||
@@ -1313,6 +1372,39 @@ 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';
|
||||||
@@ -1320,8 +1412,10 @@ 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] || [];
|
||||||
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
for (const root of hookManifestSearchRoots(ctx)) {
|
||||||
for (const root of roots) {
|
// A manifest can live above the resolved product. Honor the hook lifecycle
|
||||||
|
// config beside that manifest before treating it as active coverage.
|
||||||
|
if (!hookEnabledAt(root)) continue;
|
||||||
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,8 +18,9 @@
|
|||||||
* 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}.
|
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
|
||||||
* Detected but not auto-patched in v1.
|
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
|
||||||
|
* 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.
|
||||||
@@ -77,9 +78,57 @@ 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[] }}
|
||||||
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
|
|||||||
|
|
||||||
// === detect-only shapes ===
|
// === detect-only shapes ===
|
||||||
|
|
||||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
|
||||||
MIDDLEWARE_HINT.test(body)) {
|
|
||||||
hits.middleware.push(relPath);
|
hits.middleware.push(relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1228,14 +1228,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -1277,6 +1280,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -1650,18 +2028,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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,13 +37,30 @@ 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 finding && finding.advisory === true;
|
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function partitionAdvisory(findings) {
|
function partitionAdvisory(findings) {
|
||||||
@@ -168,6 +185,16 @@ 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,
|
||||||
@@ -185,7 +212,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)
|
http(s):// and file:// URLs; accessible linked CSS included)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
impeccable detect src/
|
impeccable detect src/
|
||||||
@@ -283,11 +310,16 @@ 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 = args.filter(a => !a.startsWith('--'));
|
const targets = expandJoinedUrlTargets(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);
|
||||||
@@ -297,13 +329,23 @@ 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 urlRe = /^(?:https?|file):\/\//i;
|
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
let browserDetector = null;
|
||||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
let browserSetupFailed = false;
|
||||||
|
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 (urlRe.test(target)) {
|
if (URL_TARGET_RE.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
|
||||||
@@ -316,14 +358,21 @@ 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) { process.stderr.write(`Error: ${e.message}\n`); }
|
} catch (e) {
|
||||||
|
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 { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
catch {
|
||||||
|
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)
|
||||||
@@ -352,7 +401,7 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = walkDir(resolved)
|
const files = walkDir(resolved, reportLocalScanFailure)
|
||||||
.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;
|
||||||
|
|
||||||
@@ -368,7 +417,11 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build import graph for multi-file awareness
|
// Build import graph for multi-file awareness
|
||||||
const graph = buildImportGraph(files);
|
const unreadableFiles = new Set();
|
||||||
|
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) {
|
||||||
@@ -379,24 +432,33 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Each file resolves its own project design system (cached by root),
|
if (unreadableFiles.has(file)) continue;
|
||||||
// so a scan spanning sibling projects applies the right rules per file.
|
try {
|
||||||
const fileOptions = scanOptionsFor(file);
|
// Each file resolves its own project design system (cached by root),
|
||||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
// so a scan spanning sibling projects applies the right rules per file.
|
||||||
// Annotate findings with import context
|
const fileOptions = scanOptionsFor(file);
|
||||||
const importers = importedByMap.get(file);
|
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||||
if (importers && importers.size > 0) {
|
// Annotate findings with import context
|
||||||
const importerNames = [...importers].map(f => path.basename(f));
|
const importers = importedByMap.get(file);
|
||||||
for (const f of fileFindings) {
|
if (importers && importers.size > 0) {
|
||||||
f.importedBy = importerNames;
|
const importerNames = [...importers].map(f => path.basename(f));
|
||||||
|
for (const f of fileFindings) {
|
||||||
|
f.importedBy = importerNames;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
allFindings.push(...fileFindings);
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(file, error);
|
||||||
}
|
}
|
||||||
allFindings.push(...fileFindings);
|
|
||||||
}
|
}
|
||||||
} else if (stat.isFile()) {
|
} else if (stat.isFile()) {
|
||||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||||
const fileOptions = scanOptionsFor(resolved);
|
try {
|
||||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
const fileOptions = scanOptionsFor(resolved);
|
||||||
|
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(target, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -413,6 +475,10 @@ 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');
|
||||||
@@ -423,10 +489,10 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||||
process.exit(primary.length > 0 ? 2 : 0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
if (jsonMode) process.stdout.write('[]\n');
|
if (jsonMode) process.stdout.write('[]\n');
|
||||||
process.exit(0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,7 +1961,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -5293,14 +5296,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8126,14 +8132,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -8175,6 +8184,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -8548,18 +8932,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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 item;
|
return deriveAdvisoryFlag(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,147 @@ 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,
|
||||||
@@ -545,8 +686,13 @@ 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) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||||
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] || '?'}`; } },
|
fmt: (m, line) => {
|
||||||
|
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||||
|
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||||
|
.find(Boolean);
|
||||||
|
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||||
|
} },
|
||||||
// --- 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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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(item);
|
findings.push(deriveAdvisoryFlag(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,6 +4,12 @@ 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 };
|
||||||
@@ -11,8 +17,7 @@ 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.
|
||||||
if (ap.advisory === true) base.advisory = true;
|
return deriveAdvisoryFlag(base);
|
||||||
return base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getAP, finding };
|
export { getAP, finding, deriveAdvisoryFlag };
|
||||||
|
|||||||
@@ -46,15 +46,20 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
|||||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
];
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir, onReadError = null) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
try {
|
||||||
|
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));
|
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
@@ -81,12 +86,19 @@ function resolveImport(specifier, fromDir, fileSet) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImportGraph(files) {
|
function buildImportGraph(files, onReadError = null) {
|
||||||
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) {
|
||||||
const content = fs.readFileSync(file, 'utf-8');
|
let content;
|
||||||
|
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,9 +588,10 @@ 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.
|
||||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
// `severity` is the canonical registry field. The runtime finding serializer
|
||||||
|
// 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.advisory === true).map(rule => rule.id),
|
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').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,7 +688,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -4020,14 +4023,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,17 +138,20 @@ 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 set is the hook's own copy of the registry's `advisory: true` rules,
|
// This legacy id fallback keeps older detector findings recognizable when they
|
||||||
// mirroring how IMMEDIATE_TIER_RULES lists rule ids inline so the hook stays
|
// carry neither the current runtime flag nor the canonical advisory severity.
|
||||||
// self-contained and testable without loading the detector. Keep it in sync
|
// Current findings are classified by their serialized metadata below.
|
||||||
// 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 && (ADVISORY_RULES.has(id) || finding.advisory === true));
|
return Boolean(id && (
|
||||||
|
ADVISORY_RULES.has(id)
|
||||||
|
|| finding.advisory === true
|
||||||
|
|| finding.severity === 'advisory'
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG = Object.freeze({
|
export const DEFAULT_CONFIG = Object.freeze({
|
||||||
|
|||||||
@@ -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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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 = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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,6 +5820,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLiveState('CYCLING');
|
setLiveState('CYCLING');
|
||||||
|
hideShaderOverlay();
|
||||||
showOrUpdateCyclingBar();
|
showOrUpdateCyclingBar();
|
||||||
saveSession();
|
saveSession();
|
||||||
completeParameterGenerationIfReady();
|
completeParameterGenerationIfReady();
|
||||||
@@ -6216,6 +6217,71 @@
|
|||||||
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) {
|
||||||
@@ -6296,7 +6362,7 @@
|
|||||||
}
|
}
|
||||||
rememberSessionFileMeta({ file: filePath });
|
rememberSessionFileMeta({ file: filePath });
|
||||||
if (isJsxSourceFile(filePath)) {
|
if (isJsxSourceFile(filePath)) {
|
||||||
const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const liveWrapper = findVariantsWrapper(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;
|
||||||
@@ -6326,14 +6392,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
|
||||||
const attempt = opts._orphanAttempt || 0;
|
probeJsxWrapperForOrphan(filePath, sessionId, opts);
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -6375,7 +6434,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const existingWrapper = findVariantsWrapper(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);
|
||||||
@@ -6532,7 +6591,7 @@
|
|||||||
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -6542,7 +6601,7 @@
|
|||||||
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
|
||||||
return svelteComponentSession.mountedVariant;
|
return svelteComponentSession.mountedVariant;
|
||||||
}
|
}
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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) {
|
||||||
@@ -6657,8 +6716,17 @@
|
|||||||
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
document.getElementById(discardStateStyleId(sessionId))?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseDiscardedStaticWrapper(wrapper, sessionId) {
|
/**
|
||||||
removeDiscardStateStylesheet(sessionId);
|
* Every wrapper a discard has to unwind. A target inside a `.map()` renders
|
||||||
|
* one wrapper per item, so the hide, the release, and the existence checks
|
||||||
|
* all have to speak about the same set.
|
||||||
|
*/
|
||||||
|
function discardedWrappers(sessionId) {
|
||||||
|
if (!sessionId) return [];
|
||||||
|
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseDiscardedStaticWrapper(wrapper) {
|
||||||
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;
|
||||||
@@ -6669,6 +6737,18 @@
|
|||||||
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;
|
||||||
@@ -6849,6 +6929,42 @@
|
|||||||
// 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
|
||||||
|
|
||||||
@@ -6878,7 +6994,7 @@
|
|||||||
}
|
}
|
||||||
if (!dominated) return;
|
if (!dominated) return;
|
||||||
|
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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"])');
|
||||||
@@ -7087,6 +7203,7 @@
|
|||||||
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();
|
||||||
@@ -7147,6 +7264,7 @@
|
|||||||
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;
|
||||||
@@ -8249,6 +8367,15 @@ 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
|
||||||
@@ -8395,14 +8522,28 @@ 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() {
|
||||||
if (!shaderState) return;
|
// Bump first, unconditionally: this is what tells an in-flight
|
||||||
|
// showShaderOverlay to abandon itself rather than publish over a session
|
||||||
|
// that has already moved on.
|
||||||
|
shaderEpoch += 1;
|
||||||
|
if (!shaderState) {
|
||||||
|
removeStrayShaderNode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shaderState.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) {
|
||||||
@@ -8427,6 +8568,16 @@ 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);
|
||||||
@@ -8449,6 +8600,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -8488,16 +8640,22 @@ 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);
|
||||||
@@ -8516,6 +8674,7 @@ 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;
|
||||||
@@ -8552,7 +8711,7 @@ void main() {
|
|||||||
clientSentAt: Date.now(),
|
clientSentAt: Date.now(),
|
||||||
};
|
};
|
||||||
if (!currentSessionId || arrivedVariants === 0) return;
|
if (!currentSessionId || arrivedVariants === 0) return;
|
||||||
const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
const acceptWrapper = findVariantsWrapper(currentSessionId);
|
||||||
if (Object.keys(paramsCurrentValues).length > 0) {
|
if (Object.keys(paramsCurrentValues).length > 0) {
|
||||||
acceptPayload.paramValues = { ...paramsCurrentValues };
|
acceptPayload.paramValues = { ...paramsCurrentValues };
|
||||||
}
|
}
|
||||||
@@ -8595,6 +8754,7 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -8646,7 +8806,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
function snapshotAcceptedVariantDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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 {
|
||||||
@@ -8773,7 +8933,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commitAcceptedVariantToDom(sessionId, variantId) {
|
function commitAcceptedVariantToDom(sessionId, variantId) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
const wrapper = findVariantsWrapper(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;
|
||||||
@@ -9001,7 +9161,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreFromActiveSessions(activeSessions, reason) {
|
function restoreFromActiveSessions(activeSessions, reason) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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);
|
||||||
@@ -9114,10 +9274,13 @@ 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).
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
// Every match, not the first: a target inside a `.map()` renders one
|
||||||
if (wrapper) {
|
// wrapper per item, and hiding only one leaves the rest of the
|
||||||
|
// discarded variants on screen.
|
||||||
|
const discardWrappers = discardedWrappers(cleanupSessionId);
|
||||||
|
if (discardWrappers.length > 0) {
|
||||||
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
|
||||||
else wrapper.style.display = 'none';
|
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none';
|
||||||
}
|
}
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
|
||||||
@@ -9125,16 +9288,19 @@ void main() {
|
|||||||
removeDiscardStateStylesheet();
|
removeDiscardStateStylesheet();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const lateWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (!lateWrapper) {
|
if (lateWrappers.length === 0) {
|
||||||
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 {
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -9143,18 +9309,20 @@ 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 staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
|
const staleWrappers = discardedWrappers(cleanupSessionId);
|
||||||
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
|
||||||
if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
|
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeDiscardStateStylesheet(cleanupSessionId);
|
removeDiscardStateStylesheet(cleanupSessionId);
|
||||||
if (staleWrapper) location.reload();
|
// A reload restores every wrapper's original at once, so there is
|
||||||
|
// nothing per-wrapper to do here.
|
||||||
|
if (staleWrappers.length > 0) location.reload();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
|
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
hideBar(instantChrome);
|
hideBar(instantChrome);
|
||||||
@@ -9342,8 +9510,13 @@ void main() {
|
|||||||
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resumeSession(recoveryRevision = liveInteractionRevision) {
|
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) {
|
||||||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
// Which path resumed matters in the journal: an init resume is a fresh
|
||||||
|
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
|
||||||
|
// used to log the same `browser_resumed`, which made issue #719 take a
|
||||||
|
// DOM reconstruction to diagnose.
|
||||||
|
const resumeReason = opts.reason || 'browser_resumed';
|
||||||
|
const wrapper = findAnyVariantsWrapper();
|
||||||
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;
|
||||||
@@ -9442,16 +9615,38 @@ void main() {
|
|||||||
|
|
||||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||||
startScrollTracking();
|
startScrollTracking();
|
||||||
// Build the params panel for the restored visible variant. Previously
|
// A resume can BE the arrival, not just a re-entry after one. The server's
|
||||||
// this was missed on page-reload resume: showVariantInDOM above fires
|
// generation preflight runs live-wrap with --defer-source-write, so the
|
||||||
// refreshParamsPanel, but state was still IDLE at that moment so it
|
// wrapper and every variant reach the DOM in one HMR batch, and the
|
||||||
// hid. Now that state is CYCLING, re-fire.
|
// deferred-wrapper scout (constructed at init) runs before the variant
|
||||||
if (state === 'CYCLING') refreshParamsPanel();
|
// MutationObserver (constructed at Go) on that batch. Finish the same
|
||||||
|
// transition the observer would have finished. Without hideShaderOverlay
|
||||||
|
// the generating shader stays frozen over the target and the session looks
|
||||||
|
// stuck at GENERATING while the bar already cycles (issue #719).
|
||||||
|
if (state === 'CYCLING') {
|
||||||
|
recoveryWaitingForAnchor = false;
|
||||||
|
hideShaderOverlay();
|
||||||
|
if (isInsert) finalizeInsertSession();
|
||||||
|
disableInlineEdit();
|
||||||
|
// Build the params panel for the restored visible variant. Previously
|
||||||
|
// this was missed on page-reload resume: showVariantInDOM above fires
|
||||||
|
// refreshParamsPanel, but state was still IDLE at that moment so it
|
||||||
|
// hid. Now that state is CYCLING, re-fire.
|
||||||
|
refreshParamsPanel();
|
||||||
|
}
|
||||||
saveSession();
|
saveSession();
|
||||||
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
|
||||||
sendCheckpoint('variants_progress');
|
sendCheckpoint('variants_progress');
|
||||||
} else {
|
} else {
|
||||||
queueCheckpoint('browser_resumed');
|
queueCheckpoint(resumeReason);
|
||||||
|
// Only variants_progress and variants_ready count as publication
|
||||||
|
// progress. When the resume is the arrival, the observer never gets to
|
||||||
|
// report it (this function disconnects and re-creates it below, which
|
||||||
|
// drops the records it had already queued for this same batch), so
|
||||||
|
// without this the server never learns the variants were published.
|
||||||
|
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||||
|
sendCheckpoint('variants_ready');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start observing for more variants AFTER initial setup
|
// Start observing for more variants AFTER initial setup
|
||||||
@@ -12773,7 +12968,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)) {
|
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) {
|
||||||
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -200,7 +200,20 @@ 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)) {
|
||||||
@@ -208,6 +221,18 @@ 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,
|
||||||
@@ -223,6 +248,18 @@ 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);
|
||||||
@@ -248,10 +285,31 @@ 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 = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
const abs = resolveTargetPath(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);
|
||||||
@@ -1151,13 +1209,19 @@ async function cli() {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const targetProvided = hasTargetOption(cliOptions);
|
const targetProvided = hasTargetOption(cliOptions);
|
||||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
const resolvedTargetPath = targetProvided
|
||||||
|
? 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(process.cwd(), cliOptions);
|
const ctx = loadContext(
|
||||||
|
process.cwd(),
|
||||||
|
resolvedTargetPath ? { targetPath: resolvedTargetPath } : cliOptions,
|
||||||
|
);
|
||||||
const updateDirective = await computeUpdateDirective();
|
const updateDirective = await computeUpdateDirective();
|
||||||
|
|
||||||
if (!ctx.hasProduct) {
|
if (!ctx.hasProduct) {
|
||||||
@@ -1271,11 +1335,6 @@ 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'],
|
||||||
@@ -1313,6 +1372,39 @@ 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';
|
||||||
@@ -1320,8 +1412,10 @@ 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] || [];
|
||||||
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
for (const root of hookManifestSearchRoots(ctx)) {
|
||||||
for (const root of roots) {
|
// A manifest can live above the resolved product. Honor the hook lifecycle
|
||||||
|
// config beside that manifest before treating it as active coverage.
|
||||||
|
if (!hookEnabledAt(root)) continue;
|
||||||
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,8 +18,9 @@
|
|||||||
* 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}.
|
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
|
||||||
* Detected but not auto-patched in v1.
|
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
|
||||||
|
* 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.
|
||||||
@@ -77,9 +78,57 @@ 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[] }}
|
||||||
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
|
|||||||
|
|
||||||
// === detect-only shapes ===
|
// === detect-only shapes ===
|
||||||
|
|
||||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
|
||||||
MIDDLEWARE_HINT.test(body)) {
|
|
||||||
hits.middleware.push(relPath);
|
hits.middleware.push(relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1228,14 +1228,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -1277,6 +1280,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -1650,18 +2028,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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,13 +37,30 @@ 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 finding && finding.advisory === true;
|
return Boolean(finding && (finding.advisory === true || finding.severity === 'advisory'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function partitionAdvisory(findings) {
|
function partitionAdvisory(findings) {
|
||||||
@@ -168,6 +185,16 @@ 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,
|
||||||
@@ -185,7 +212,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)
|
http(s):// and file:// URLs; accessible linked CSS included)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
impeccable detect src/
|
impeccable detect src/
|
||||||
@@ -283,11 +310,16 @@ 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 = args.filter(a => !a.startsWith('--'));
|
const targets = expandJoinedUrlTargets(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);
|
||||||
@@ -297,13 +329,23 @@ 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 urlRe = /^(?:https?|file):\/\//i;
|
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length;
|
||||||
const urlTargetCount = paths.filter(target => urlRe.test(target)).length;
|
let browserDetector = null;
|
||||||
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
|
let browserSetupFailed = false;
|
||||||
|
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 (urlRe.test(target)) {
|
if (URL_TARGET_RE.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
|
||||||
@@ -316,14 +358,21 @@ 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) { process.stderr.write(`Error: ${e.message}\n`); }
|
} catch (e) {
|
||||||
|
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 { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
catch {
|
||||||
|
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)
|
||||||
@@ -352,7 +401,7 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = walkDir(resolved)
|
const files = walkDir(resolved, reportLocalScanFailure)
|
||||||
.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;
|
||||||
|
|
||||||
@@ -368,7 +417,11 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build import graph for multi-file awareness
|
// Build import graph for multi-file awareness
|
||||||
const graph = buildImportGraph(files);
|
const unreadableFiles = new Set();
|
||||||
|
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) {
|
||||||
@@ -379,24 +432,33 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// Each file resolves its own project design system (cached by root),
|
if (unreadableFiles.has(file)) continue;
|
||||||
// so a scan spanning sibling projects applies the right rules per file.
|
try {
|
||||||
const fileOptions = scanOptionsFor(file);
|
// Each file resolves its own project design system (cached by root),
|
||||||
const fileFindings = await detectLocalFile(file, fileOptions);
|
// so a scan spanning sibling projects applies the right rules per file.
|
||||||
// Annotate findings with import context
|
const fileOptions = scanOptionsFor(file);
|
||||||
const importers = importedByMap.get(file);
|
const fileFindings = await detectLocalFile(file, fileOptions);
|
||||||
if (importers && importers.size > 0) {
|
// Annotate findings with import context
|
||||||
const importerNames = [...importers].map(f => path.basename(f));
|
const importers = importedByMap.get(file);
|
||||||
for (const f of fileFindings) {
|
if (importers && importers.size > 0) {
|
||||||
f.importedBy = importerNames;
|
const importerNames = [...importers].map(f => path.basename(f));
|
||||||
|
for (const f of fileFindings) {
|
||||||
|
f.importedBy = importerNames;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
allFindings.push(...fileFindings);
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(file, error);
|
||||||
}
|
}
|
||||||
allFindings.push(...fileFindings);
|
|
||||||
}
|
}
|
||||||
} else if (stat.isFile()) {
|
} else if (stat.isFile()) {
|
||||||
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
|
||||||
const fileOptions = scanOptionsFor(resolved);
|
try {
|
||||||
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
const fileOptions = scanOptionsFor(resolved);
|
||||||
|
allFindings.push(...await detectLocalFile(resolved, fileOptions));
|
||||||
|
} catch (error) {
|
||||||
|
reportLocalScanFailure(target, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -413,6 +475,10 @@ 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');
|
||||||
@@ -423,10 +489,10 @@ async function detectCli() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||||
process.exit(primary.length > 0 ? 2 : 0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
if (jsonMode) process.stdout.write('[]\n');
|
if (jsonMode) process.stdout.write('[]\n');
|
||||||
process.exit(0);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,7 +1961,10 @@ 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;
|
||||||
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
// Ignore delimiters inside comments when locating the previous declaration.
|
||||||
|
// Keeping comment length intact preserves indices into the original source.
|
||||||
|
const beforeOpen = cssText.slice(0, open).replace(/\/\*[\s\S]*?\*\//g, comment => ' '.repeat(comment.length));
|
||||||
|
const prevClose = Math.max(beforeOpen.lastIndexOf('}'), beforeOpen.lastIndexOf(';'));
|
||||||
const 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`
|
||||||
@@ -5293,14 +5296,17 @@ function checkTypography() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (totalTextElements >= 20) {
|
if (totalTextElements >= 20) {
|
||||||
// A font is "primary" if it's used by at least 15% of text elements
|
// Report the actual primary face: the uniquely most-used family. The old
|
||||||
const PRIMARY_THRESHOLD = 0.15;
|
// 15% threshold labeled secondary faces as primary (e.g. an 82/18 split).
|
||||||
for (const [font, count] of fontUsage) {
|
const ranked = [...fontUsage.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
const [primary] = ranked;
|
||||||
|
const tied = ranked[1]?.[1] === primary?.[1];
|
||||||
|
if (primary && !tied) {
|
||||||
|
const [font, count] = primary;
|
||||||
const share = count / totalTextElements;
|
const share = count / totalTextElements;
|
||||||
if (share < PRIMARY_THRESHOLD) continue;
|
if (OVERUSED_FONTS.has(font) && !isBrandFontOnOwnDomain(font)) {
|
||||||
if (!OVERUSED_FONTS.has(font)) continue;
|
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
||||||
if (isBrandFontOnOwnDomain(font)) continue;
|
}
|
||||||
findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8126,14 +8132,17 @@ 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: f.severity || ap?.severity || 'warning',
|
severity,
|
||||||
// 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.
|
||||||
advisory: (ap && ap.advisory === true) || f.advisory === true,
|
// Per-finding promotions override the registry default, so derive
|
||||||
|
// 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),
|
||||||
@@ -8175,6 +8184,381 @@ 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 }));
|
||||||
}
|
}
|
||||||
@@ -8548,18 +8932,16 @@ 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 scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
const html = docClone.outerHTML;
|
||||||
|
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 query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
const matches = selectorNodesForLiveDom(document, f.selector);
|
||||||
if (!query || /^[,\s]*$/.test(query)) return true;
|
if (!matches) return false;
|
||||||
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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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 item;
|
return deriveAdvisoryFlag(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -498,6 +498,147 @@ 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,
|
||||||
@@ -545,8 +686,13 @@ 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) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
|
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)),
|
||||||
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] || '?'}`; } },
|
fmt: (m, line) => {
|
||||||
|
const bg = grayOnColorPairs(line, m[0], m.index)
|
||||||
|
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
|
||||||
|
.find(Boolean);
|
||||||
|
return `${m[0]} on ${bg?.[0] || '?'}`;
|
||||||
|
} },
|
||||||
// --- 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 { finding } from '../../findings.mjs';
|
import { deriveAdvisoryFlag, 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(item);
|
findings.push(deriveAdvisoryFlag(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,6 +4,12 @@ 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 };
|
||||||
@@ -11,8 +17,7 @@ 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.
|
||||||
if (ap.advisory === true) base.advisory = true;
|
return deriveAdvisoryFlag(base);
|
||||||
return base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getAP, finding };
|
export { getAP, finding, deriveAdvisoryFlag };
|
||||||
|
|||||||
@@ -46,15 +46,20 @@ const IMPORT_SPECIFIER_PATTERNS = [
|
|||||||
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
|
||||||
];
|
];
|
||||||
|
|
||||||
function walkDir(dir) {
|
function walkDir(dir, onReadError = null) {
|
||||||
const files = [];
|
const files = [];
|
||||||
let entries;
|
let entries;
|
||||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
|
try {
|
||||||
|
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));
|
if (entry.isDirectory()) files.push(...walkDir(full, onReadError));
|
||||||
else if (hasScannableExtension(entry.name)) files.push(full);
|
else if (hasScannableExtension(entry.name)) files.push(full);
|
||||||
}
|
}
|
||||||
return files;
|
return files;
|
||||||
@@ -81,12 +86,19 @@ function resolveImport(specifier, fromDir, fileSet) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildImportGraph(files) {
|
function buildImportGraph(files, onReadError = null) {
|
||||||
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) {
|
||||||
const content = fs.readFileSync(file, 'utf-8');
|
let content;
|
||||||
|
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.
|
||||||
advisory: true,
|
severity: 'advisory',
|
||||||
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,9 +588,10 @@ 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.
|
||||||
// The set is derived from the registry so a rule only needs `advisory: true`.
|
// `severity` is the canonical registry field. The runtime finding serializer
|
||||||
|
// 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.advisory === true).map(rule => rule.id),
|
ANTIPATTERNS.filter(rule => rule.severity === 'advisory').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
Reference in New Issue
Block a user