Compare commits

..
Author SHA1 Message Date
Paul Bakaus 52380df1ad Report unreadable detector directories
AI assistance disclosure: Codex implemented and tested this fix under maintainer direction.
2026-09-02 19:12:03 -07:00
Paul Bakaus 840965fc77 Handle unreadable detector targets
AI assistance disclosure: Codex implemented and tested this fix under maintainer direction.
2026-09-02 19:01:05 -07:00
Paul Bakaus 2001c81686 Fix local target failure exit codes
AI assistance disclosure: Codex implemented and tested this fix under maintainer direction.
2026-09-02 18:48:43 -07:00
Paul Bakaus 7437368526 Fix URL scan failure exit codes
Return exit 1 when browser setup or a URL scan fails, including partial multi-target scans, while preserving JSON findings output. Document the detector exit contract and cover isolated installs without Puppeteer.\n\nAI assistance disclosure: Codex implemented and tested this fix under maintainer direction.
2026-09-02 18:39:31 -07:00
1781 changed files with 110994 additions and 171094 deletions
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
-2
View File
@@ -1,2 +0,0 @@
[alias]
xtask = "run --quiet --package xtask --"
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
-6
View File
@@ -1,6 +0,0 @@
# The oracle replays goldens recorded from a POSIX checkout, and a finding's
# snippet carries the fixture's own bytes, so these files have to arrive with
# LF on every platform. `-text` disables end-of-line conversion outright, which
# is also safe for any binary that lands under these trees.
tests/fixtures/** -text
tests/oracle/** -text
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
+6 -185
View File
@@ -23,7 +23,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
core: ${{ steps.plan.outputs.core }} core: ${{ steps.plan.outputs.core }}
rust: ${{ steps.plan.outputs.rust }}
detector: ${{ steps.plan.outputs.detector }} detector: ${{ steps.plan.outputs.detector }}
live: ${{ steps.plan.outputs.live }} live: ${{ steps.plan.outputs.live }}
framework: ${{ steps.plan.outputs.framework }} framework: ${{ steps.plan.outputs.framework }}
@@ -93,22 +92,13 @@ jobs:
if: needs.changes.outputs.framework == 'true' if: needs.changes.outputs.framework == 'true'
run: bun run test:framework run: bun run test:framework
- name: Rebuild browser detector
if: needs.changes.outputs.detector == 'true'
run: bun run build:browser
- name: Build - name: Build
run: bun run build run: bun run build
# `bun run build:extension` runs `cargo xtask bundle`: the rule core
# compiled to wasm plus the page JS in browser-bundle/.
- name: Install the pinned toolchain
if: needs.changes.outputs.detector == 'true'
run: rustup show && rustup target add wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.detector == 'true'
- name: Install wasm-pack
if: needs.changes.outputs.detector == 'true'
run: cargo install wasm-pack --locked
- name: Build extension - name: Build extension
if: needs.changes.outputs.detector == 'true' if: needs.changes.outputs.detector == 'true'
run: bun run build:extension run: bun run build:extension
@@ -121,131 +111,18 @@ jobs:
run: npx --yes web-ext@10 lint --source-dir dist/extension-firefox run: npx --yes web-ext@10 lint --source-dir dist/extension-firefox
- name: Verify generated tracked outputs - name: Verify generated tracked outputs
# extension/detector/ is gitignored (built by `cargo xtask bundle`); run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin cli/engine/detect-antipatterns-browser.js extension/detector
# it stays listed so a stray tracked copy shows up here.
run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin extension/detector
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
name: impeccable-build-node-${{ matrix.node-version }} name: impeccable-dist-node-${{ matrix.node-version }}
# Ship the packaged zips, not the unpacked Firefox staging tree. # Ship the packaged zips, not the unpacked Firefox staging tree.
path: | path: |
dist/ dist/
!dist/extension-firefox/ !dist/extension-firefox/
retention-days: 7 retention-days: 7
# The Rust workspace: the engine binary, the rule core, and every crate
# behind them. Everything builds from source with no downloads.
rust:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.rust == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v7
# rust-toolchain.toml names the channel; `rustup show` installs it.
# Never override the toolchain here.
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --workspace --all-targets
- name: Test
run: cargo test --workspace
# The engine ships a windows-x64 binary (release-engine.yml), so the
# workspace has to build and pass its own tests there. Tests that need a
# browser or the oracle skip when those are absent.
rust-windows:
runs-on: windows-latest
needs: changes
if: needs.changes.outputs.rust == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- run: cargo build --workspace --all-targets
- run: cargo test --workspace --no-fail-fast
# Behavior gate: replays the tests/oracle/ goldens against a release build
# of the engine from THIS checkout (so a PR is judged on its own source,
# not on the last published binary). Without this job the oracle only ever
# runs on developer laptops: tests/oracle.test.mjs skips cleanly when no
# binary is present, so the default suite is silent about it on CI.
oracle:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.oracle == 'true' || needs.changes.outputs.rust == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build the engine from source
run: cargo build --release -p impeccable
- name: Replay oracle goldens
env:
IMPECCABLE_BIN: ${{ github.workspace }}/target/release/impeccable
run: node tests/oracle/run.mjs
# Release-order guard (triage decision D4). Verifies that the engine release for
# the pinned ENGINE_VERSION is fully published — the five dist binaries + .sha256
# AND the five @impeccable/cli-<os>-<arch> npm platform packages — before a skill
# release/merge that depends on them. The launcher, npm shim, and
# `impeccable install` all dead-end without those assets.
#
# continue-on-error is a release-time toggle: until the first engine release is
# published, the assets cannot exist and this job would block
# every PR. It emits a loud ::warning instead. Once v<ENGINE_VERSION> is live,
# flip `continue-on-error` to false so a MIS-ORDERED release (skill/CLI ahead of
# the engine) fails CI. release.mjs already hard-fails `release:skill`/`release:cli`.
engine-release-ready:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Check engine release assets for pinned ENGINE_VERSION
id: check
continue-on-error: true
run: node scripts/check-engine-release.mjs
- name: Annotate missing engine release
if: steps.check.outcome != 'success'
run: |
echo "::warning title=Engine release not ready::The engine release for v$(cat ENGINE_VERSION) is not fully published (engine-v$(cat ENGINE_VERSION) release) and/or the @impeccable/cli-<os>-<arch> npm platform packages. Releasing the skill/CLI (or merging) now would dead-end the launcher, the npm shim, and impeccable install. Expected until the first engine release exists; after that, publish the engine + platform packages and flip this job's continue-on-error to false so a mis-ordered release fails CI."
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test-matrix needs: test-matrix
@@ -280,16 +157,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
# The live verbs are the engine binary; build it from this checkout so the
# suite tests the branch, not the last published release.
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build the engine
run: cargo build --release -p impeccable
- name: Run remote CLI E2E smoke - name: Run remote CLI E2E smoke
run: bun run test:cli-remote-e2e run: bun run test:cli-remote-e2e
@@ -345,16 +212,6 @@ jobs:
- name: Install Playwright Chromium - name: Install Playwright Chromium
run: npx playwright install chromium run: npx playwright install chromium
# The live verbs are the engine binary; build it from this checkout so the
# suite tests the branch, not the last published release.
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build the engine
run: cargo build --release -p impeccable
- name: Run live E2E tests - name: Run live E2E tests
run: bun run test:live-e2e run: bun run test:live-e2e
env: env:
@@ -431,16 +288,6 @@ jobs:
- name: Install Playwright Chromium - name: Install Playwright Chromium
run: npx playwright install chromium run: npx playwright install chromium
# The live verbs are the engine binary; build it from this checkout so the
# suite tests the branch, not the last published release.
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build the engine
run: cargo build --release -p impeccable
- name: Run live E2E tests - name: Run live E2E tests
run: bun run test:live-e2e run: bun run test:live-e2e
env: env:
@@ -513,19 +360,6 @@ jobs:
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: npx playwright install chromium run: npx playwright install chromium
# The live verbs are the engine binary; build it from this checkout so the
# suite tests the branch, not the last published release.
- name: Install the pinned toolchain
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: rustup show
- uses: Swatinem/rust-cache@v2
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
- name: Build the engine
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: cargo build --release -p impeccable
- name: Run accept cleanup regression - name: Run accept cleanup regression
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }} if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: | run: |
@@ -590,19 +424,6 @@ jobs:
if: ${{ env.DEEPSEEK_API_KEY != '' }} if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: npx playwright install chromium run: npx playwright install chromium
# The live verbs are the engine binary; build it from this checkout so the
# suite tests the branch, not the last published release.
- name: Install the pinned toolchain
if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: rustup show
- uses: Swatinem/rust-cache@v2
if: ${{ env.DEEPSEEK_API_KEY != '' }}
- name: Build the engine
if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: cargo build --release -p impeccable
- name: Run Svelte adapter DeepSeek sweep - name: Run Svelte adapter DeepSeek sweep
if: ${{ env.DEEPSEEK_API_KEY != '' }} if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: bun run test:live-svelte-adapter-deepseek run: bun run test:live-svelte-adapter-deepseek
-87
View File
@@ -1,87 +0,0 @@
name: release-engine
# Builds the engine binary for every supported target and publishes them, with
# sha256 sidecars, as the GitHub Release `engine-v<X>` on this repo. That
# release is what the launcher (skill/scripts/impeccable), the npm shim
# (cli/bin/cli.js), `impeccable install`, and `bun run fetch:engine` download.
#
# Trigger: `bun run release:engine` (scripts/release.mjs) verifies
# ENGINE_VERSION, the npm platform-package pins, and a clean tree, then
# pushes the tag. Third-party actions are pinned to commit SHAs so a
# moved tag cannot swap the code this workflow runs.
on:
push:
tags: ['engine-v*']
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- { os: macos-14, target: aarch64-apple-darwin, short: darwin-arm64 }
# No Intel runner: GitHub retired macos-13. Apple's toolchain builds
# x86_64 on an arm64 host natively once the target is installed.
- { os: macos-14, target: x86_64-apple-darwin, short: darwin-x64 }
- { os: ubuntu-latest, target: x86_64-unknown-linux-musl, short: linux-x64 }
- { os: ubuntu-latest, target: aarch64-unknown-linux-musl, short: linux-arm64, cross: true }
- { os: windows-latest, target: x86_64-pc-windows-msvc, short: windows-x64 }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check the tag matches ENGINE_VERSION
shell: bash
run: |
set -e
want="engine-v$(tr -d '[:space:]' < ENGINE_VERSION)"
[ "$GITHUB_REF_NAME" = "$want" ] || { echo "tag $GITHUB_REF_NAME != $want"; exit 1; }
# rust-toolchain.toml names the channel; `rustup show` installs it.
# Never override the toolchain here.
- name: Install the pinned toolchain
shell: bash
run: rustup show && rustup target add ${{ matrix.target }}
- if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- if: matrix.cross
run: cargo install cross --locked
- name: Build
shell: bash
run: ${{ matrix.cross && 'cross' || 'cargo' }} build --release -p impeccable --target ${{ matrix.target }}
- name: Smoke the binary
if: ${{ !matrix.cross }}
shell: bash
run: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }} engine-probe
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: impeccable-${{ matrix.short }}
path: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }}
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: { path: artifacts }
- name: Lay out release assets with checksums
run: |
set -e
mkdir -p out
for d in artifacts/impeccable-*; do
short=$(basename "$d" | sed 's/^impeccable-//')
f=$(ls "$d" | head -1)
case "$short" in windows-*) dest="out/impeccable-$short.exe" ;; *) dest="out/impeccable-$short" ;; esac
cp "$d/$f" "$dest"
(cd out && sha256sum "$(basename "$dest")" > "$(basename "$dest").sha256")
done
ls -la out
- name: Publish the GitHub Release
env: { GH_TOKEN: "${{ github.token }}" }
# No --clobber: a published asset is immutable. A re-run against an
# existing release fails on the first existing asset instead of
# silently replacing a binary and its sidecar hash.
run: |
set -e
tag="${GITHUB_REF_NAME}"
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "impeccable engine $tag" \
--notes "Prebuilt impeccable engine binaries ($tag). The launcher, the npm shim and impeccable install download these on first run. Docs: https://impeccable.style" out/* || \
gh release upload "$tag" out/* --repo "$GITHUB_REPOSITORY"
-12
View File
@@ -13,17 +13,10 @@ build/
# can copy them into tmp git repos and assert is-generated behavior. # can copy them into tmp git repos and assert is-generated behavior.
!tests/framework-fixtures/**/dist/ !tests/framework-fixtures/**/dist/
!tests/framework-fixtures/**/dist/** !tests/framework-fixtures/**/dist/**
# Same for the oracle workspaces: live-html carries a dist/generated.html
# that the generated-file cases point at.
!tests/oracle/workspaces/**/dist/
!tests/oracle/workspaces/**/dist/**
# Build artifacts # Build artifacts
*.log *.log
# Cargo (the Rust workspace; Cargo.lock IS tracked, it pins the engine build)
/target/
# OS files # OS files
.DS_Store .DS_Store
Thumbs.db Thumbs.db
@@ -90,11 +83,6 @@ src/lib/impeccable/__runtime.js
# Extension build artifacts # Extension build artifacts
extension/detector/ extension/detector/
# Engine binaries: fetched per platform (scripts/fetch-engine.mjs), never tracked.
# The launcher next to them (skill/scripts/impeccable) is the tracked file.
skill/scripts/bin/
**/skills/impeccable/scripts/bin/
# Legacy design context (pre-v3.1, auto-migrated to PRODUCT.md by load-context.mjs) # Legacy design context (pre-v3.1, auto-migrated to PRODUCT.md by load-context.mjs)
.impeccable.md .impeccable.md
# Note: PRODUCT.md and DESIGN.md are INTENTIONALLY tracked in this repo — # Note: PRODUCT.md and DESIGN.md are INTENTIONALLY tracked in this repo —
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
@@ -189,12 +189,6 @@ Output streams:
Human-readable findings go to stderr so stdout stays available for structured 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. 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,
@@ -315,11 +309,6 @@ async function detectCli() {
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);
@@ -330,22 +319,11 @@ async function detectCli() {
// browser-grade scan of a local artifact can pass file:///abs/path.html // browser-grade scan of a local artifact can pass file:///abs/path.html
// instead of the bare path (which stays on the static engine). // instead of the bare path (which stays on the static engine).
const urlTargetCount = paths.filter(target => URL_TARGET_RE.test(target)).length; const urlTargetCount = paths.filter(target => URL_TARGET_RE.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 (URL_TARGET_RE.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
@@ -358,21 +336,14 @@ async function detectCli() {
? (url) => browserDetector.detectUrl(url, urlOptions) ? (url) => browserDetector.detectUrl(url, urlOptions)
: (url) => detectUrl(url, urlOptions); : (url) => detectUrl(url, urlOptions);
allFindings.push(...await scanner(target)); allFindings.push(...await scanner(target));
} catch (e) { } catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
hadOperationalFailure = true;
process.stderr.write(`Error: ${e.message}\n`);
}
continue; continue;
} }
const resolved = path.resolve(target); const resolved = path.resolve(target);
let stat; let stat;
try { stat = fs.statSync(resolved); } try { stat = fs.statSync(resolved); }
catch { catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
hadOperationalFailure = true;
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) { if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output) // Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
@@ -401,7 +372,7 @@ async function detectCli() {
} }
} }
const files = walkDir(resolved, reportLocalScanFailure) const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig)); .filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
@@ -417,11 +388,7 @@ async function detectCli() {
} }
// Build import graph for multi-file awareness // Build import graph for multi-file awareness
const unreadableFiles = new Set(); const graph = buildImportGraph(files);
const graph = buildImportGraph(files, (file, error) => {
unreadableFiles.add(file);
reportLocalScanFailure(file, error);
});
// Build reverse map: file -> set of files that import it // Build reverse map: file -> set of files that import it
const importedByMap = new Map(); const importedByMap = new Map();
for (const [importer, imports] of graph) { for (const [importer, imports] of graph) {
@@ -432,33 +399,24 @@ async function detectCli() {
} }
for (const file of files) { for (const file of files) {
if (unreadableFiles.has(file)) continue; // Each file resolves its own project design system (cached by root),
try { // so a scan spanning sibling projects applies the right rules per file.
// Each file resolves its own project design system (cached by root), const fileOptions = scanOptionsFor(file);
// so a scan spanning sibling projects applies the right rules per file. const fileFindings = await detectLocalFile(file, fileOptions);
const fileOptions = scanOptionsFor(file); // Annotate findings with import context
const fileFindings = await detectLocalFile(file, fileOptions); const importers = importedByMap.get(file);
// Annotate findings with import context if (importers && importers.size > 0) {
const importers = importedByMap.get(file); const importerNames = [...importers].map(f => path.basename(f));
if (importers && importers.size > 0) { for (const f of fileFindings) {
const importerNames = [...importers].map(f => path.basename(f)); f.importedBy = importerNames;
for (const f of fileFindings) {
f.importedBy = importerNames;
}
} }
allFindings.push(...fileFindings);
} catch (error) {
reportLocalScanFailure(file, error);
} }
allFindings.push(...fileFindings);
} }
} else if (stat.isFile()) { } else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
try { const fileOptions = scanOptionsFor(resolved);
const fileOptions = scanOptionsFor(resolved); allFindings.push(...await detectLocalFile(resolved, fileOptions));
allFindings.push(...await detectLocalFile(resolved, fileOptions));
} catch (error) {
reportLocalScanFailure(target, error);
}
} }
} }
} finally { } finally {
@@ -475,10 +433,6 @@ async function detectCli() {
// advisory-only scan still prints its notes but exits 0 (a clean pass), so // advisory-only scan still prints its notes but exits 0 (a clean pass), so
// advisory rules never break CI or block automation. // advisory rules never break CI or block automation.
const { primary, advisory } = partitionAdvisory(allFindings); const { primary, advisory } = partitionAdvisory(allFindings);
// Exit 1 means at least one requested scan could not complete. It takes
// precedence over exit 2 because findings from the remaining targets do not
// turn a partial scan into a complete one.
const exitCode = hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0);
if (allFindings.length > 0) { if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
@@ -489,10 +443,10 @@ async function detectCli() {
} }
} }
else process.stderr.write(formatFindings(allFindings, false) + '\n'); else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(exitCode); process.exit(primary.length > 0 ? 2 : 0);
} }
if (jsonMode) process.stdout.write('[]\n'); if (jsonMode) process.stdout.write('[]\n');
process.exit(exitCode); process.exit(0);
} }
export { formatFindings, handleStdin, confirm, printUsage, detectCli }; export { formatFindings, handleStdin, confirm, printUsage, detectCli };
@@ -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]}` });
} }
@@ -498,147 +498,6 @@ function isNeutralBorderColor(str) {
return isNeutralAuthoredColor(m[1]); return isNeutralAuthoredColor(m[1]);
} }
const TW_SOLID_CHROMATIC_BG_RE = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+(?!\/)\b/;
function scanJs(text, start, onChar) {
let stringQuote = '';
let inTemplate = false;
let paren = 0;
let brace = 0;
const interpBrace = [];
for (let i = start; i < text.length; i++) {
const char = text[i];
const prev = text[i - 1];
const next = text[i + 1];
if (stringQuote) {
if (char === '\\') { i++; continue; }
if (char === stringQuote) stringQuote = '';
continue;
}
if (inTemplate && interpBrace.length === 0) {
if (char === '\\') { i++; continue; }
if (char === '$' && next === '{') {
brace++;
interpBrace.push(brace);
i++;
continue;
}
if (char === '`') { inTemplate = false; continue; }
continue;
}
if (char === "'" || char === '"') { stringQuote = char; continue; }
if (char === '`') { inTemplate = true; continue; }
if (char === '(') { paren++; continue; }
if (char === ')') { paren--; continue; }
if (char === '{') { brace++; continue; }
if (char === '}') {
brace--;
if (interpBrace.length && brace < interpBrace[interpBrace.length - 1]) interpBrace.pop();
continue;
}
if (onChar(char, i, prev, next, { paren, brace })) return;
}
}
function containingMarkupTag(line, index) {
let i = 0;
while (i < line.length) {
const tagStart = line.indexOf('<', i);
if (tagStart === -1) break;
if (!/^<[A-Za-z]/.test(line.slice(tagStart))) {
i = tagStart + 1;
continue;
}
let tagEnd = -1;
scanJs(line, tagStart + 1, (char, j, _p, _n, depth) => {
if (char === '>' && depth.brace === 0) {
tagEnd = j;
return true;
}
return false;
});
if (tagEnd === -1) break;
if (index >= tagStart && index <= tagEnd) {
return { text: line.slice(tagStart, tagEnd + 1), start: tagStart };
}
i = tagEnd + 1;
}
return { text: line, start: 0 };
}
function findTernarySplit(text) {
let qPos = -1;
let qParen = 0;
let qBrace = 0;
let nested = 0;
let colonPos = -1;
let split = null;
const isQuestion = (char, prev, next) =>
char === '?' && prev !== '.' && prev !== '?' && next !== '?' && next !== '.';
const sameDepth = (depth) => depth.paren === qParen && depth.brace === qBrace;
scanJs(text, 0, (char, i, prev, next, depth) => {
if (colonPos === -1) {
if (qPos === -1 && isQuestion(char, prev, next)) {
qPos = i;
qParen = depth.paren;
qBrace = depth.brace;
return false;
}
if (qPos !== -1 && isQuestion(char, prev, next) && sameDepth(depth)) {
nested++;
return false;
}
if (qPos !== -1 && char === ':' && sameDepth(depth)) {
if (nested) nested--;
else colonPos = i;
}
return false;
}
if (char === ',' && sameDepth(depth)) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1, i),
suffix: text.slice(i),
};
return true;
}
return false;
});
if (!split && qPos !== -1 && colonPos !== -1) {
split = {
common: text.slice(0, qPos),
consequent: text.slice(qPos + 1, colonPos),
alternate: text.slice(colonPos + 1),
suffix: '',
};
}
return split;
}
function exclusiveClassScopes(text) {
const split = findTernarySplit(text);
if (!split) return [text];
return [
...exclusiveClassScopes(split.consequent).map((part) => split.common + part + split.suffix),
...exclusiveClassScopes(split.alternate).map((part) => split.common + part + split.suffix),
];
}
function grayOnColorScopes(line, index) {
return exclusiveClassScopes(containingMarkupTag(line, index).text);
}
function grayOnColorPairs(line, grayClass, index) {
return grayOnColorScopes(line, index).filter((scope) => scope.includes(grayClass));
}
const REGEX_MATCHERS = [ const REGEX_MATCHERS = [
// --- Side-tab --- // --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g, { id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
@@ -686,13 +545,8 @@ const REGEX_MATCHERS = [
fmt: () => 'bg-clip-text + bg-gradient' }, fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg --- // --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g, { id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => grayOnColorPairs(line, m[0], m.index).some((scope) => TW_SOLID_CHROMATIC_BG_RE.test(scope)), test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
const bg = grayOnColorPairs(line, m[0], m.index)
.map((scope) => scope.match(TW_SOLID_CHROMATIC_BG_RE))
.find(Boolean);
return `${m[0]} on ${bg?.[0] || '?'}`;
} },
// --- Tailwind AI palette --- // --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g, { id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line), test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
@@ -46,20 +46,15 @@ const IMPORT_SPECIFIER_PATTERNS = [
/@(?:use|forward)\s+['"]([^'"]+)['"]/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g,
]; ];
function walkDir(dir, onReadError = null) { function walkDir(dir) {
const files = []; const files = [];
let entries; let entries;
try { try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (onReadError) onReadError(dir, error);
return files;
}
for (const entry of entries) { for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue; if (SKIP_DIRS.has(entry.name)) continue;
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue; if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full, onReadError)); if (entry.isDirectory()) files.push(...walkDir(full));
else if (hasScannableExtension(entry.name)) files.push(full); else if (hasScannableExtension(entry.name)) files.push(full);
} }
return files; return files;
@@ -86,19 +81,12 @@ function resolveImport(specifier, fromDir, fileSet) {
return null; return null;
} }
function buildImportGraph(files, onReadError = null) { function buildImportGraph(files) {
const fileSet = new Set(files); const fileSet = new Set(files);
const graph = new Map(); const graph = new Map();
for (const file of files) { for (const file of files) {
let content; const content = fs.readFileSync(file, 'utf-8');
try {
content = fs.readFileSync(file, 'utf-8');
} catch (error) {
if (!onReadError) throw error;
onReadError(file, error);
continue;
}
const dir = path.dirname(file); const dir = path.dirname(file);
const imports = new Set(); const imports = new Set();
@@ -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]}` });
} }
+45 -240
View File
@@ -2060,7 +2060,7 @@
if (anchor) return anchor; if (anchor) return anchor;
} }
if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { if (currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (wrapper) { if (wrapper) {
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0 && visibleVariant > 0) { if (variantCount > 0 && visibleVariant > 0) {
@@ -2131,14 +2131,14 @@
function isInsertGeneratingSession() { function isInsertGeneratingSession() {
if (state !== 'GENERATING' || !currentSessionId) return false; if (state !== 'GENERATING' || !currentSessionId) return false;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
return !!wrapper && wrapper.dataset.impeccableMode === 'insert'; return !!wrapper && wrapper.dataset.impeccableMode === 'insert';
} }
/** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */ /** Recreate the dotted placeholder if Astro/Vite HMR removed it mid-generation. */
function ensureInsertPlaceholder() { function ensureInsertPlaceholder() {
if (!isInsertGeneratingSession()) return placeholderElement; if (!isInsertGeneratingSession()) return placeholderElement;
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length; const variantCount = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])').length;
if (variantCount > 0) return placeholderElement; if (variantCount > 0) return placeholderElement;
if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement; if (placeholderElement && document.body.contains(placeholderElement)) return placeholderElement;
@@ -3156,7 +3156,7 @@
|| svelteComponentSession.wrapperEl || svelteComponentSession.wrapperEl
|| null; || null;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return null; if (!wrapper) return null;
return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]'); return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
} }
@@ -4900,7 +4900,7 @@
return Object.values(svelteComponentSession.paramsByVariant || {}) return Object.values(svelteComponentSession.paramsByVariant || {})
.reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0); .reduce((total, params) => total + (Array.isArray(params) ? params.length : 0), 0);
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')] return [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')]
.reduce((total, variant) => total + parseVariantParams(variant).length, 0); .reduce((total, variant) => total + parseVariantParams(variant).length, 0);
@@ -5004,7 +5004,7 @@
scheduleCyclingBarSync(sessionId, num); scheduleCyclingBarSync(sessionId, num);
return true; return true;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
updateVariantStateStylesheet(sessionId, num); updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't // Unconditional refresh - covers first-reveal (no-op if state isn't
@@ -5820,7 +5820,6 @@
return; return;
} }
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
saveSession(); saveSession();
completeParameterGenerationIfReady(); completeParameterGenerationIfReady();
@@ -6217,71 +6216,6 @@
return /\.[cm]?[jt]sx$/i.test(String(filePath || '')); return /\.[cm]?[jt]sx$/i.test(String(filePath || ''));
} }
function sourceHasSessionWrapper(text, sessionId) {
const src = String(text || '');
return src.indexOf('data-impeccable-variants="' + sessionId + '"') !== -1
|| src.indexOf("data-impeccable-variants='" + sessionId + "'") !== -1
|| src.indexOf('impeccable-variants-start ' + sessionId) !== -1;
}
/**
* Orphan probe for JSX targets (#439 + #454). An unmounted wrapper and a
* wrapper deleted from source look identical in the DOM, and only the second
* is an orphan, so the DOM alone cannot decide. #454 forbids parsing or
* injecting raw JSX; reading the file as plain text and matching the session
* marker honors that, because no DOM is ever built from what comes back.
* Marker present means the component is simply not mounted right now (a
* closed modal, another route) and the variant observer keeps waiting.
* Marker absent after the same retry budget the HTML path uses means the
* file was edited out from under the session, which no reload, HMR push, or
* server restart can repair, so the session self-discards and hands the
* surface back to the picker.
*/
function probeJsxWrapperForOrphan(filePath, sessionId, opts) {
const attempt = opts._orphanAttempt || 0;
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
const stillActive = () => sessionId === currentSessionId && (state === 'GENERATING' || state === 'CYCLING');
const retryLater = () => {
setTimeout(() => {
if (!stillActive()) return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
};
// Discarding is durable (the session moves to the discarded phase and the
// picker replaces it), so it needs evidence that the wrapper is gone: a
// read that answers without the marker, or a 404 (the file itself was
// renamed or deleted). Either kind retries on the shared budget first.
// A read that fails for any other reason (the server briefly away, a
// transient fetch error) says nothing about the wrapper; after the budget
// the session is kept, the user told, and the next event retries.
const onNoWrapper = (reason) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
discardOrphanedSession(reason);
};
const onUnreadable = (detail) => {
if (!stillActive()) return;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) { retryLater(); return; }
console.warn('[impeccable] Could not read source to check the variant wrapper; keeping the session: ' + detail);
showToast('Could not read the source file to check this session; it stays open and is checked again on the next event.', 5500);
};
fetch(url)
.then(r => { if (!r.ok) throw new Error('source read failed: ' + r.status); return r.text(); })
.then(text => {
if (!stillActive()) return;
if (sourceHasSessionWrapper(text, sessionId)) return;
onNoWrapper('variant wrapper missing from source');
})
.catch(err => {
const detail = err && err.message ? err.message : 'fetch failed';
if (/source read failed: 404$/.test(detail)) {
onNoWrapper('source file missing (404) while checking for the variant wrapper');
return;
}
onUnreadable(detail);
});
}
function completeSourceInjection(wrapper, sessionId, opts) { function completeSourceInjection(wrapper, sessionId, opts) {
recoveryWaitingForAnchor = false; recoveryWaitingForAnchor = false;
if (pendingVariantAnchorRetryObserver) { if (pendingVariantAnchorRetryObserver) {
@@ -6362,7 +6296,7 @@
} }
rememberSessionFileMeta({ file: filePath }); rememberSessionFileMeta({ file: filePath });
if (isJsxSourceFile(filePath)) { if (isJsxSourceFile(filePath)) {
const liveWrapper = findVariantsWrapper(sessionId); const liveWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) { if (liveWrapper && liveWrapper.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath }); completeSourceInjection(liveWrapper, sessionId, { ...opts, filePath });
return; return;
@@ -6392,7 +6326,14 @@
return; return;
} }
if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) { if (opts.orphanDiscard && !liveWrapper && sessionId === currentSessionId) {
probeJsxWrapperForOrphan(filePath, sessionId, opts); const attempt = opts._orphanAttempt || 0;
if (attempt < COMPLETED_SOURCE_FALLBACK_RETRIES) {
setTimeout(() => {
if (sessionId !== currentSessionId) return;
if (state !== 'GENERATING' && state !== 'CYCLING') return;
injectVariantsFromSource(filePath, sessionId, { ...opts, _orphanAttempt: attempt + 1 });
}, COMPLETED_SOURCE_FALLBACK_RETRY_MS);
}
} }
return; return;
} }
@@ -6434,7 +6375,7 @@
return; return;
} }
const existingWrapper = findVariantsWrapper(sessionId); const existingWrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (existingWrapper) { if (existingWrapper) {
const wrapper = srcWrapper.cloneNode(true); const wrapper = srcWrapper.cloneNode(true);
existingWrapper.parentElement.replaceChild(wrapper, existingWrapper); existingWrapper.parentElement.replaceChild(wrapper, existingWrapper);
@@ -6591,7 +6532,7 @@
if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor; if (anchor && !anchor.__impeccableFrozenAnchor) selectedElement = anchor;
return; return;
} }
const wrapper = findVariantsWrapper(currentSessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const visEl = pickVariantContent(wrapper, visibleVariant); const visEl = pickVariantContent(wrapper, visibleVariant);
if (visEl) selectedElement = visEl; if (visEl) selectedElement = visEl;
@@ -6601,7 +6542,7 @@
if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) { if (svelteComponentSession?.sessionId === sessionId && svelteComponentSession.mountedVariant > 0) {
return svelteComponentSession.mountedVariant; return svelteComponentSession.mountedVariant;
} }
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return 0; if (!wrapper) return 0;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
for (const variant of variants) { for (const variant of variants) {
@@ -6716,17 +6657,8 @@
document.getElementById(discardStateStyleId(sessionId))?.remove(); document.getElementById(discardStateStyleId(sessionId))?.remove();
} }
/** function releaseDiscardedStaticWrapper(wrapper, sessionId) {
* Every wrapper a discard has to unwind. A target inside a `.map()` renders removeDiscardStateStylesheet(sessionId);
* one wrapper per item, so the hide, the release, and the existence checks
* all have to speak about the same set.
*/
function discardedWrappers(sessionId) {
if (!sessionId) return [];
return [...document.querySelectorAll('[data-impeccable-variants="' + sessionId + '"]')];
}
function releaseDiscardedStaticWrapper(wrapper) {
if (!wrapper) return; if (!wrapper) return;
const orig = wrapper.querySelector('[data-impeccable-variant="original"]'); const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
const content = orig?.firstElementChild; const content = orig?.firstElementChild;
@@ -6737,18 +6669,6 @@
wrapper.remove(); wrapper.remove();
} }
/**
* Undo the discard hide on every wrapper it covered. Releasing only the
* first match left the other mapped items sitting at display:none with
* their original content never restored, on exactly the static and
* missed-HMR flows this fallback exists for.
*/
function releaseDiscardedStaticWrappers(sessionId, wrappers) {
removeDiscardStateStylesheet(sessionId);
const set = wrappers && wrappers.length ? wrappers : discardedWrappers(sessionId);
for (const wrapper of set) releaseDiscardedStaticWrapper(wrapper);
}
function watchForDiscardedFrameworkWrapperRemoval(sessionId) { function watchForDiscardedFrameworkWrapperRemoval(sessionId) {
if (!sessionId || !document.body) return; if (!sessionId || !document.body) return;
if (discardedFrameworkWrapperWatchers.has(sessionId)) return; if (discardedFrameworkWrapperWatchers.has(sessionId)) return;
@@ -6929,42 +6849,6 @@
// MutationObserver for progressive variant reveal // MutationObserver for progressive variant reveal
// //
// A session id can have more than one wrapper in the DOM: the target may sit
// inside a `.map()` callback (the wrapper renders once per item), or the
// agent may have relocated the wrapper out of the shared primitive live-wrap
// scaffolded into. A plain first match can then pin an empty scaffold while
// the real variants sit in a later wrapper, which strands the session at
// 0/N and leaves the bar, the params panel, and accept all reading the
// wrong element. Prefer a wrapper that actually holds variants. With zero
// or one match this is exactly the querySelector it replaces.
//
// Every lookup of the ACTIVE session's wrapper goes through here. The
// remaining raw `[data-impeccable-variants=...]` uses are deliberate: bare
// existence checks, selector strings for stylesheets and observers (which
// want to cover every match), `querySelectorAll` sweeps, and the parsed
// source document, which is not this document.
function pickPopulatedVariantsWrapper(selector) {
const matches = document.querySelectorAll(selector);
if (matches.length < 2) return matches[0] || null;
for (const candidate of matches) {
if (candidate.querySelector('[data-impeccable-variant]:not([data-impeccable-variant="original"])')) {
return candidate;
}
}
return matches[0];
}
/** The wrapper holding `sessionId`'s variants, or null without an id. */
function findVariantsWrapper(sessionId) {
if (!sessionId) return null;
return pickPopulatedVariantsWrapper('[data-impeccable-variants="' + sessionId + '"]');
}
/** Any live variant wrapper, for the resume paths that have no id yet. */
function findAnyVariantsWrapper() {
return pickPopulatedVariantsWrapper('[data-impeccable-variants]');
}
function startVariantObserver(sessionId) { function startVariantObserver(sessionId) {
let updating = false; // re-entrancy guard let updating = false; // re-entrancy guard
@@ -6994,7 +6878,7 @@
} }
if (!dominated) return; if (!dominated) return;
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return; if (!wrapper) return;
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
@@ -7203,7 +7087,6 @@
if (arrivedVariants >= expectedVariants && expectedVariants > 0) { if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
if (state === 'GENERATING') { if (state === 'GENERATING') {
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
disableInlineEdit(); disableInlineEdit();
refreshParamsPanel(); refreshParamsPanel();
@@ -7264,7 +7147,6 @@
pendingAcceptedSession = null; pendingAcceptedSession = null;
awaitingAcceptResult = null; awaitingAcceptResult = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
updateBarContent('cycling'); updateBarContent('cycling');
showToast('Could not complete accept cleanup. Try Accept again.', 5000); showToast('Could not complete accept cleanup. Try Accept again.', 5000);
break; break;
@@ -8367,15 +8249,6 @@ void main() {
// matches the original off-white risograph paper. // matches the original off-white risograph paper.
const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955]; const SHADER_PAPER_FALLBACK = [0.975, 0.965, 0.955];
let shaderState = null; // { canvas, gl, program, texture, rafId, startTime } let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
// showShaderOverlay is async: it appends its canvas, then awaits
// createImageBitmap and the GL setup before it publishes shaderState. A
// teardown that landed inside that window found shaderState still null,
// returned, and then watched the construction publish itself over a session
// that had already left GENERATING, with no teardown left to run. That is
// the generating loader frozen over a page that already cycles (issue #719).
// Every teardown bumps this epoch; a construction abandons its own canvas as
// soon as it sees the epoch move.
let shaderEpoch = 0;
// The element's effective background tone, used as the uniform halftone // The element's effective background tone, used as the uniform halftone
// ground so content dissolves into dots over it. Unlike resolveCanvasBackground // ground so content dissolves into dots over it. Unlike resolveCanvasBackground
@@ -8522,28 +8395,14 @@ void main() {
}); });
} }
/** Drop a shader node no shaderState owns (an abandoned construction). */
function removeStrayShaderNode() {
const stray = uiGetById(PREFIX + '-shader');
if (stray) stray.remove();
}
function hideShaderOverlay() { function hideShaderOverlay() {
// Bump first, unconditionally: this is what tells an in-flight if (!shaderState) return;
// showShaderOverlay to abandon itself rather than publish over a session
// that has already moved on.
shaderEpoch += 1;
if (!shaderState) {
removeStrayShaderNode();
return;
}
if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId); if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
if (shaderState.canvas) shaderState.canvas.remove(); if (shaderState.canvas) shaderState.canvas.remove();
if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl); if (shaderState.objectUrl) URL.revokeObjectURL(shaderState.objectUrl);
const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context'); const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
shaderState = null; shaderState = null;
removeStrayShaderNode();
} }
function showShaderBitmapFallback(canvas, blob) { function showShaderBitmapFallback(canvas, blob) {
@@ -8568,16 +8427,6 @@ void main() {
async function showShaderOverlay(el, blob, rect, paper) { async function showShaderOverlay(el, blob, rect, paper) {
hideShaderOverlay(); hideShaderOverlay();
if (!blob || !el) return; if (!blob || !el) return;
// hideShaderOverlay just bumped the epoch, so this run owns it until the
// next teardown. Every step past an await re-checks before it publishes.
const epoch = shaderEpoch;
const abandoned = (node, gl) => {
if (epoch === shaderEpoch) return false;
node.remove();
const lose = gl?.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {}
return true;
};
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.id = PREFIX + '-shader'; canvas.id = PREFIX + '-shader';
const dpr = Math.min(window.devicePixelRatio || 1, 2); const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -8600,7 +8449,6 @@ void main() {
if (!gl) { if (!gl) {
// WebGL unavailable: use the captured bitmap as a background overlay so // WebGL unavailable: use the captured bitmap as a background overlay so
// the user still sees something meaningful during generation. // the user still sees something meaningful during generation.
if (abandoned(canvas, null)) return;
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
@@ -8640,22 +8488,16 @@ void main() {
} }
// Upload the screenshot as a texture // Upload the screenshot as a texture
if (abandoned(canvas, gl)) return;
let bitmap; let bitmap;
try { try {
bitmap = await createImageBitmap(blob); bitmap = await createImageBitmap(blob);
} catch (err) { } catch (err) {
console.warn('[impeccable] shader bitmap decode failed:', err); console.warn('[impeccable] shader bitmap decode failed:', err);
if (abandoned(canvas, gl)) return;
const lose = gl.getExtension?.('WEBGL_lose_context'); const lose = gl.getExtension?.('WEBGL_lose_context');
try { lose?.loseContext(); } catch {} try { lose?.loseContext(); } catch {}
showShaderBitmapFallback(canvas, blob); showShaderBitmapFallback(canvas, blob);
return; return;
} }
if (abandoned(canvas, gl)) {
if (bitmap.close) bitmap.close();
return;
}
texture = gl.createTexture(); texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture); gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
@@ -8674,7 +8516,6 @@ void main() {
const paperRgb = paper || resolvePaperRgb(el); const paperRgb = paper || resolvePaperRgb(el);
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (abandoned(canvas, gl)) return;
shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced }; shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
function frame() { function frame() {
if (!shaderState) return; if (!shaderState) return;
@@ -8711,7 +8552,7 @@ void main() {
clientSentAt: Date.now(), clientSentAt: Date.now(),
}; };
if (!currentSessionId || arrivedVariants === 0) return; if (!currentSessionId || arrivedVariants === 0) return;
const acceptWrapper = findVariantsWrapper(currentSessionId); const acceptWrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
if (Object.keys(paramsCurrentValues).length > 0) { if (Object.keys(paramsCurrentValues).length > 0) {
acceptPayload.paramValues = { ...paramsCurrentValues }; acceptPayload.paramValues = { ...paramsCurrentValues };
} }
@@ -8754,7 +8595,6 @@ void main() {
.catch(() => { .catch(() => {
if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null; if (pendingAcceptedSession?.id === acceptedSessionId) pendingAcceptedSession = null;
setLiveState('CYCLING'); setLiveState('CYCLING');
hideShaderOverlay();
showOrUpdateCyclingBar(); showOrUpdateCyclingBar();
showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000); showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
}); });
@@ -8806,7 +8646,7 @@ void main() {
} }
function snapshotAcceptedVariantDom(sessionId, variantId) { function snapshotAcceptedVariantDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper?.querySelector?.('[data-impeccable-variant="' + variantId + '"]');
const root = accepted?.firstElementChild || null; const root = accepted?.firstElementChild || null;
return { return {
@@ -8933,7 +8773,7 @@ void main() {
} }
function commitAcceptedVariantToDom(sessionId, variantId) { function commitAcceptedVariantToDom(sessionId, variantId) {
const wrapper = findVariantsWrapper(sessionId); const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false; if (!wrapper) return false;
const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]'); const accepted = wrapper.querySelector('[data-impeccable-variant="' + variantId + '"]');
if (!accepted || !accepted.firstElementChild) return false; if (!accepted || !accepted.firstElementChild) return false;
@@ -9161,7 +9001,7 @@ void main() {
} }
function restoreFromActiveSessions(activeSessions, reason) { function restoreFromActiveSessions(activeSessions, reason) {
const wrapper = findAnyVariantsWrapper(); const wrapper = document.querySelector('[data-impeccable-variants]');
if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false; if (wrapper && !isFrameworkComponentPreviewMode(wrapper.dataset.impeccablePreview)) return false;
if (svelteComponentSession?.sessionId === currentSessionId) return false; if (svelteComponentSession?.sessionId === currentSessionId) return false;
return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions); return restoreSessionWithoutWrapper(reason || 'sse_connected', activeSessions);
@@ -9274,13 +9114,10 @@ void main() {
// reconciler later tries to remove a wrapper we already removed. // reconciler later tries to remove a wrapper we already removed.
// Schedule a 2s fallback that does the manual swap only if HMR hasn't // Schedule a 2s fallback that does the manual swap only if HMR hasn't
// replaced the wrapper by then (keeps static-server / no-HMR flows alive). // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
// Every match, not the first: a target inside a `.map()` renders one const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
// wrapper per item, and hiding only one leaves the rest of the if (wrapper) {
// discarded variants on screen.
const discardWrappers = discardedWrappers(cleanupSessionId);
if (discardWrappers.length > 0) {
if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId); if (restoreOriginal) showOriginalDuringDiscard(cleanupSessionId);
else for (const discardWrapper of discardWrappers) discardWrapper.style.display = 'none'; else wrapper.style.display = 'none';
} }
setTimeout(function() { setTimeout(function() {
const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision); const recoverySuperseded = deferredRecoverySuperseded(cleanupSessionId, cleanupRevision);
@@ -9288,19 +9125,16 @@ void main() {
removeDiscardStateStylesheet(); removeDiscardStateStylesheet();
return; return;
} }
const lateWrappers = discardedWrappers(cleanupSessionId); const lateWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (lateWrappers.length === 0) { if (!lateWrapper) {
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
return; return;
} }
// Duplicates all render from one source element, so HMR ownership is
// uniform across them; the first is a fair witness for the set.
const lateWrapper = lateWrappers[0];
if (recoverySuperseded) { if (recoverySuperseded) {
if (hasFrameworkHmrOwnership(lateWrapper)) { if (hasFrameworkHmrOwnership(lateWrapper)) {
watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
} else { } else {
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
} }
return; return;
} }
@@ -9309,20 +9143,18 @@ void main() {
// the final source rewrite, reload once after a grace window so the // the final source rewrite, reload once after a grace window so the
// discarded source becomes authoritative without a reconciler race. // discarded source becomes authoritative without a reconciler race.
setTimeout(function() { setTimeout(function() {
const staleWrappers = discardedWrappers(cleanupSessionId); const staleWrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) { if (deferredRecoverySuperseded(cleanupSessionId, cleanupRevision)) {
if (staleWrappers.length === 0) removeDiscardStateStylesheet(cleanupSessionId); if (!staleWrapper) removeDiscardStateStylesheet(cleanupSessionId);
else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId); else watchForDiscardedFrameworkWrapperRemoval(cleanupSessionId);
return; return;
} }
removeDiscardStateStylesheet(cleanupSessionId); removeDiscardStateStylesheet(cleanupSessionId);
// A reload restores every wrapper's original at once, so there is if (staleWrapper) location.reload();
// nothing per-wrapper to do here.
if (staleWrappers.length > 0) location.reload();
}, 2000); }, 2000);
return; return;
} }
releaseDiscardedStaticWrappers(cleanupSessionId, lateWrappers); releaseDiscardedStaticWrapper(lateWrapper, cleanupSessionId);
}, 2000); }, 2000);
} }
hideBar(instantChrome); hideBar(instantChrome);
@@ -9510,13 +9342,8 @@ void main() {
return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper'); return restoreSessionWithoutWrapper('browser_resumed_over_handled_wrapper');
} }
function resumeSession(recoveryRevision = liveInteractionRevision, opts = {}) { function resumeSession(recoveryRevision = liveInteractionRevision) {
// Which path resumed matters in the journal: an init resume is a fresh const wrapper = document.querySelector('[data-impeccable-variants]');
// page load, the deferred-wrapper scout is a mid-page-load arrival. Both
// used to log the same `browser_resumed`, which made issue #719 take a
// DOM reconstruction to diagnose.
const resumeReason = opts.reason || 'browser_resumed';
const wrapper = findAnyVariantsWrapper();
const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]'); const runtimeWrapper = wrapper || document.querySelector('[data-impeccable-carbonize]');
if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true; if (restoreSessionSupersedingHandledWrapper(runtimeWrapper)) return true;
if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false; if (scheduleHandledRuntimeWrapperReload(runtimeWrapper, recoveryRevision)) return false;
@@ -9615,38 +9442,16 @@ void main() {
showBar(state === 'CYCLING' ? 'cycling' : 'generating'); showBar(state === 'CYCLING' ? 'cycling' : 'generating');
startScrollTracking(); startScrollTracking();
// A resume can BE the arrival, not just a re-entry after one. The server's // Build the params panel for the restored visible variant. Previously
// generation preflight runs live-wrap with --defer-source-write, so the // this was missed on page-reload resume: showVariantInDOM above fires
// wrapper and every variant reach the DOM in one HMR batch, and the // refreshParamsPanel, but state was still IDLE at that moment so it
// deferred-wrapper scout (constructed at init) runs before the variant // hid. Now that state is CYCLING, re-fire.
// MutationObserver (constructed at Go) on that batch. Finish the same if (state === 'CYCLING') refreshParamsPanel();
// transition the observer would have finished. Without hideShaderOverlay
// the generating shader stays frozen over the target and the session looks
// stuck at GENERATING while the bar already cycles (issue #719).
if (state === 'CYCLING') {
recoveryWaitingForAnchor = false;
hideShaderOverlay();
if (isInsert) finalizeInsertSession();
disableInlineEdit();
// Build the params panel for the restored visible variant. Previously
// this was missed on page-reload resume: showVariantInDOM above fires
// refreshParamsPanel, but state was still IDLE at that moment so it
// hid. Now that state is CYCLING, re-fire.
refreshParamsPanel();
}
saveSession(); saveSession();
if (arrivedVariants > 0 && arrivedVariants < expectedVariants) { if (arrivedVariants > 0 && arrivedVariants < expectedVariants) {
sendCheckpoint('variants_progress'); sendCheckpoint('variants_progress');
} else { } else {
queueCheckpoint(resumeReason); queueCheckpoint('browser_resumed');
// Only variants_progress and variants_ready count as publication
// progress. When the resume is the arrival, the observer never gets to
// report it (this function disconnects and re-creates it below, which
// drops the records it had already queued for this same batch), so
// without this the server never learns the variants were published.
if (arrivedVariants > 0 && arrivedVariants >= expectedVariants && expectedVariants > 0) {
sendCheckpoint('variants_ready');
}
} }
// Start observing for more variants AFTER initial setup // Start observing for more variants AFTER initial setup
@@ -12968,7 +12773,7 @@ void main() {
const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]'); const wrapper = document.querySelector('[data-impeccable-variants],[data-impeccable-carbonize]');
if (!wrapper) return; if (!wrapper) return;
scout.disconnect(); scout.disconnect();
if (resumeSession(deferredResumeRevision, { reason: 'browser_resumed_deferred_wrapper' })) { if (resumeSession(deferredResumeRevision)) {
console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).'); console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
} }
}); });
+22 -11
View File
@@ -2,7 +2,7 @@
## Project Structure & Module Organization ## Project Structure & Module Organization
`skill/` is the source of truth for the Impeccable skill: `SKILL.src.md`, `reference/`, `scripts/`, and `agents/`. `skill/scripts/` holds the launcher (`impeccable`, `impeccable.cmd`), the pinned engine `VERSION`, `command-metadata.json`, and the in-page live-mode JS; every skill verb (`{{scripts_path}}/impeccable <verb>`) runs in the engine binary, which is built in a separate repo and pinned by the root `ENGINE_VERSION` file. Build logic lives in `scripts/`, with provider configs in `scripts/lib/transformers/`. `cli/` is the npm shim that runs the same binary, the browser extension lives in `extension/`, and regression coverage in `tests/` with fixtures under `tests/fixtures/` and the behavior goldens under `tests/oracle/`. `dist/` and `build/` are generated and gitignored. The root harness folders (`.agents/`, `.claude/`, `.cursor/`, etc.) and `plugin/` are generated distribution artifacts that are tracked for direct repo installs, not hand-authored source. `skill/` is the source of truth for the Impeccable skill: `SKILL.src.md`, `reference/`, `scripts/`, and `agents/`. Build logic lives in `scripts/`, with provider configs in `scripts/lib/transformers/`. The CLI and anti-pattern detector live in `cli/`, the browser extension in `extension/`, the Astro website in `site/`, Cloudflare Pages Functions in `functions/`, and regression coverage in `tests/` with fixtures under `tests/fixtures/`. `dist/` and `build/` are generated and gitignored. The root harness folders (`.agents/`, `.claude/`, `.cursor/`, etc.) and `plugin/` are generated distribution artifacts that are tracked for direct repo installs, not hand-authored source.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
@@ -12,12 +12,11 @@
- `bun run rebuild` - clean and rebuild everything from scratch without syncing tracked harness folders. - `bun run rebuild` - clean and rebuild everything from scratch without syncing tracked harness folders.
- `bun run rebuild:release` - clean and rebuild everything, including tracked harness output sync. - `bun run rebuild:release` - clean and rebuild everything, including tracked harness output sync.
- `bun test tests/build.test.js` - run a focused Bun test. - `bun test tests/build.test.js` - run a focused Bun test.
- `bun run fetch:engine` - download the pinned engine binary for this machine into `skill/scripts/bin/<os>-<arch>/` (or set `IMPECCABLE_BIN` to a local build). The oracle and framework suites skip without it. - `bun run test` - run the full Bun + Node test suite (includes the plugin loader E2E, which installs the committed `plugin/` subtree into a sandboxed real Claude Code and skips cleanly when the `claude` CLI is absent).
- `bun run test` - run the full Bun + Node test suite (includes the oracle replay against the engine binary and the plugin loader E2E, which installs the committed `plugin/` subtree into a sandboxed real Claude Code and skips cleanly when the `claude` CLI is absent).
- `bun run test:live-e2e` - opt-in live-mode E2E against framework fixtures (~2 min; needs `npx playwright install chromium` once). - `bun run test:live-e2e` - opt-in live-mode E2E against framework fixtures (~2 min; needs `npx playwright install chromium` once).
- `bun run test:skill-behavior` - opt-in LLM-backed checks that the SKILL.md Setup flow actually drives the agent (runs claude-sonnet-5 / gpt-5.6-luna / gemini-3.5-flash / deepseek-v4-flash; needs `.env` with provider keys). - `bun run test:skill-behavior` - opt-in LLM-backed checks that the SKILL.md Setup flow actually drives the agent (runs claude-sonnet-5 / gpt-5.6-luna / gemini-3.5-flash / deepseek-v4-flash; needs `.env` with provider keys).
- `bun run test:plugin-e2e` - just the plugin loader E2E, for fast iteration on `plugin/`, `skill/agents/`, or `scripts/build.js` changes. - `bun run test:plugin-e2e` - just the plugin loader E2E, for fast iteration on `plugin/`, `skill/agents/`, or `scripts/build.js` changes.
- `bun run build:extension` - rebuild the extension bundle (it runs `cargo xtask bundle`, which also refreshes the in-page detector bundle). - `bun run build:browser` / `bun run build:extension` - rebuild browser-specific bundles.
Run `bun run build` after changing anything in `skill/`, transformer code, or user-facing counts. It validates the generated distribution under `dist/` without touching tracked root harness outputs. Use `bun run build:release` only when intentionally refreshing generated provider permutations for release/main-sync or build-system work. Run `bun run build` after changing anything in `skill/`, transformer code, or user-facing counts. It validates the generated distribution under `dist/` without touching tracked root harness outputs. Use `bun run build:release` only when intentionally refreshing generated provider permutations for release/main-sync or build-system work.
@@ -33,27 +32,39 @@ Some repo workflows need to run outside the sandbox in the desktop app:
- GitHub SSH operations that depend on the 1Password SSH agent, such as `gh pr checkout`, may fail in the sandbox with `sign_and_send_pubkey` or no 1Password approval prompt. Rerun them outside the sandbox instead of falling back to unrelated workarounds. - GitHub SSH operations that depend on the 1Password SSH agent, such as `gh pr checkout`, may fail in the sandbox with `sign_and_send_pubkey` or no 1Password approval prompt. Rerun them outside the sandbox instead of falling back to unrelated workarounds.
- `bun run build:release` rewrites committed harness directories such as `.agents/skills/`. In the sandbox, Bun can hit filesystem errors while removing/recreating those trees (for example `EFAULT` on `.agents/skills`). Rerun the release build outside the sandbox before treating it as a real build failure. - `bun run build:release` rewrites committed harness directories such as `.agents/skills/`. In the sandbox, Bun can hit filesystem errors while removing/recreating those trees (for example `EFAULT` on `.agents/skills`). Rerun the release build outside the sandbox before treating it as a real build failure.
- The oracle and framework suites spawn the engine binary many times; run them with Node (`node --test tests/oracle.test.mjs`), which is what `bun run test` does. - Puppeteer/headless-Chrome tests, especially `node --test tests/detect-antipatterns-browser.test.mjs` and the browser portion of `bun run test`, can hang in the sandbox while launching Chrome. Run them outside the sandbox for authoritative results.
- The jsdom fixture suite is intentionally run with Node, not Bun: use `node --test tests/detect-antipatterns-fixtures.test.mjs` or the `bun run test` script. A direct `bun test tests/detect-antipatterns-fixtures.test.mjs` can time out and is not the supported signal.
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
Use ESM, semicolons, and the existing two-space indentation style in JS, HTML, and CSS. Prefer small, single-purpose modules over large abstractions. Keep filenames descriptive and lowercase with hyphens where needed; skill entrypoints stay as `SKILL.md`, build and test helpers use `.js` or `.mjs`. In source frontmatter, use clear kebab-case names and concise descriptions. There is no dedicated formatter or linter configured here, so match surrounding code closely. Use ESM, semicolons, and the existing two-space indentation style in JS, HTML, and CSS. Prefer small, single-purpose modules over large abstractions. Keep filenames descriptive and lowercase with hyphens where needed; skill entrypoints stay as `SKILL.md`, helper scripts use `.js` or `.mjs`. In source frontmatter, use clear kebab-case names and concise descriptions. There is no dedicated formatter or linter configured here, so match surrounding code closely.
## Testing Guidelines ## Testing Guidelines
Tests use Buns test runner plus Nodes built-in `--test`. Name tests `*.test.js` or `*.test.mjs` and place new fixtures near the behavior they cover, usually under `tests/fixtures/`. Prefer targeted test runs while iterating, then finish with `bun run test`. If you change generated outputs or provider transforms, verify both source parsing and at least one affected provider path in `dist/`. Tests use Buns test runner plus Nodes built-in `--test`. Name tests `*.test.js` or `*.test.mjs` and place new fixtures near the behavior they cover, usually under `tests/fixtures/`. Prefer targeted test runs while iterating, then finish with `bun run test`. If you change generated outputs or provider transforms, verify both source parsing and at least one affected provider path in `dist/`.
For changes to the live-mode page JS (`skill/scripts/live-browser*.js`) or an `ENGINE_VERSION` bump, also run `bun run test:live-e2e` (kept out of the default suite because it does real `npm install` per fixture and boots framework dev servers). Scope to one fixture with `IMPECCABLE_E2E_ONLY=<fixture-name>` while iterating; pass `IMPECCABLE_E2E_DEBUG=1` for page-DOM and dev-server-log dumps on failure. Schema and authoring guide for new fixtures live in `tests/framework-fixtures/README.md`. For changes to `skill/scripts/live-*.{mjs,js}` or `skill/scripts/live/**`, also run `bun run test:live-e2e` (kept out of the default suite because it does real `npm install` per fixture and boots framework dev servers). Scope to one fixture with `IMPECCABLE_E2E_ONLY=<fixture-name>` while iterating; pass `IMPECCABLE_E2E_DEBUG=1` for page-DOM and dev-server-log dumps on failure. Schema and authoring guide for new fixtures live in `tests/framework-fixtures/README.md`.
Set `IMPECCABLE_E2E_AGENT=llm` to swap the deterministic fake agent for an API-backed one (`tests/live-e2e/agents/llm-agent.mjs`). Claude Haiku 4.5 is the primary path whenever `ANTHROPIC_API_KEY` is set. DeepSeek V4 Flash is the secondary cheap fallback when only `DEEPSEEK_API_KEY` is set, and can be forced with `IMPECCABLE_E2E_LLM_PROVIDER=deepseek` or `bun run test:live-e2e -- --llm-provider=deepseek`; override either model via `IMPECCABLE_E2E_LLM_MODEL` or `--llm-model=<model>`. Tests skip cleanly when the selected provider key is unset. This path hits the API — use it for verification, not CI. Set `IMPECCABLE_E2E_AGENT=llm` to swap the deterministic fake agent for an API-backed one (`tests/live-e2e/agents/llm-agent.mjs`). Claude Haiku 4.5 is the primary path whenever `ANTHROPIC_API_KEY` is set. DeepSeek V4 Flash is the secondary cheap fallback when only `DEEPSEEK_API_KEY` is set, and can be forced with `IMPECCABLE_E2E_LLM_PROVIDER=deepseek` or `bun run test:live-e2e -- --llm-provider=deepseek`; override either model via `IMPECCABLE_E2E_LLM_MODEL` or `--llm-model=<model>`. Tests skip cleanly when the selected provider key is unset. This path hits the API — use it for verification, not CI.
For changes to `skill/SKILL.src.md`'s Setup section or any Setup-touching reference file (`init.md`, `document.md`, `brand.md`, `product.md`, sub-command refs), also run `bun run test:skill-behavior`. The suite spawns current real models (claude-sonnet-5, gpt-5.6-luna, gemini-3.5-flash, deepseek-v4-flash) with the source SKILL.md inlined as system prompt and a workspace-scoped tool set, then asserts on the tool-call trace. Provider keys live in repo-root `.env`; missing keys skip cleanly. Scope to one provider with `IMPECCABLE_SKILL_BEHAVIOR_MODELS=<id>`; add `IMPECCABLE_SKILL_BEHAVIOR_VERBOSE=1` to dump per-scenario traces. Baseline and per-scenario assertions live in `tests/skill-behavior/README.md`. For changes to `skill/SKILL.src.md`'s Setup section, `skill/scripts/context.mjs`, or any Setup-touching reference file (`init.md`, `document.md`, `brand.md`, `product.md`, sub-command refs), also run `bun run test:skill-behavior`. The suite spawns current real models (claude-sonnet-5, gpt-5.6-luna, gemini-3.5-flash, deepseek-v4-flash) with the source SKILL.md inlined as system prompt and a workspace-scoped tool set, then asserts on the tool-call trace. Provider keys live in repo-root `.env`; missing keys skip cleanly. Scope to one provider with `IMPECCABLE_SKILL_BEHAVIOR_MODELS=<id>`; add `IMPECCABLE_SKILL_BEHAVIOR_VERBOSE=1` to dump per-scenario traces. Baseline and per-scenario assertions live in `tests/skill-behavior/README.md`.
Other area-to-suite obligations (the canonical mapping is the `triggers` lists in `scripts/test-suites.mjs`; CLAUDE.md carries the full table): an `ENGINE_VERSION` bump owes `bun run test:new-work-e2e` (Playwright, offline), `bun run test:live-e2e-accept-cleanup` (provider-billed), and `bun run test:live-svelte-adapter-deepseek` (DeepSeek-billed) on top of the default run. Other area-to-suite obligations (the canonical mapping is the `triggers` lists in `scripts/test-suites.mjs`; CLAUDE.md carries the full table): `serve-question.mjs` / `generate-image.mjs` / `concept-seed.mjs` changes owe `bun run test:new-work-e2e` (Playwright, offline); `cli/bin/commands/skills.mjs` changes owe `bun run test:cli-remote-e2e` (hits impeccable.style); accept/browser/server/wrap or SvelteKit adapter changes owe `bun run test:live-e2e-accept-cleanup` (provider-billed), and Svelte adapter/component changes owe `bun run test:live-svelte-adapter-deepseek` (DeepSeek-billed).
## Anti-pattern detection rules ## Anti-pattern detection rules
The rule engine lives in the engine repo, not here. What this repo owns is the behavior contract: `docs/CLI-CONTRACT.md` describes every verb, `tests/oracle/` holds the recorded goldens and replays them against the binary (`tests/oracle.test.mjs`), and `tests/fixtures/antipatterns/*.html` are the fixtures those goldens scan. A rule change lands in the engine, then here as a new oracle case (`node tests/oracle/record.mjs --bin <prefix>`, golden reviewed by hand) and, when it introduces new design guidance, an edit to `skill/SKILL.src.md` or `skill/reference/*.md`. Rule counts quoted in `README.md` and `README.npm.md` are checked by the build against `extension/detector/antipatterns.json` when that vendored file is present. `cli/engine/detect-antipatterns.mjs` is the source of truth for the rule engine. It feeds the CLI, the site overlay (`cli/engine/detect-antipatterns-browser.js`, regenerated by `bun run build:browser`), the Chrome extension (`extension/detector/`, regenerated by `bun run build:extension`), and the homepage `DETECTION_COUNT` in `site/public/js/generated/counts.js` (regenerated by `bun run build`). After any rule change run all three builds plus `bun run test` so nothing drifts.
TDD order is non-negotiable:
1. Add a fixture at `tests/fixtures/antipatterns/{rule-id}.html` with two columns (should-flag / should-pass), each case identified by a unique heading. ≥4 flag cases and ≥5 false-positive shapes. **Use explicit pixel dimensions in CSS** — jsdom does no layout.
2. Add a failing test in `tests/detect-antipatterns-fixtures.test.mjs` using the snippet-substring pattern (regex `/"([^"]+)"/` against `SHOULD_FLAG` / `SHOULD_PASS` lists).
3. Add the rule entry to the `ANTIPATTERNS` array (`id`, `category` = `slop` or `quality`, `name`, `description`, optional `skillSection` / `skillGuideline`).
4. Implement a pure `checkXxx(opts)` returning `[{ id, snippet }]` — no DOM access inside.
5. Add two adapters that wrap the pure check: `checkElementXxxDOM(el)` for the browser (`getComputedStyle` + `getBoundingClientRect`) and `checkElementXxx(el, tag, window)` for jsdom (`parseFloat(style.width)` instead of layout). Wire **both** adapters into **both** element loops in `cli/engine/detect-antipatterns.mjs` (browser loop ~line 1837, jsdom loop in `detectHtml` ~line 2058). Forgetting one is the most common mistake.
6. Verify on a live page at `http://localhost:4321/fixtures/antipatterns/{rule-id}.html` and on the homepage. The two adapter paths can disagree.
Conventions: wrap the identifying heading text in straight double quotes inside snippets so the fixture test can extract it. jsdom-specific helpers `resolveBackground()`, `resolveGradientStops()`, and `parseGradientColors()` exist because `background:` shorthand isn't decomposed and computed colors aren't normalized in jsdom — use them. Reference rules to copy from: `side-tab` (border), `low-contrast` (color+gradient), `icon-tile-stack` (sibling relationship), `flat-type-hierarchy` (page-level).
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
@@ -77,4 +88,4 @@ Tags are per-component because the three components ship independently: `skill-v
## Contributor Notes ## Contributor Notes
Do not edit generated provider files directly unless you are intentionally patching generated output as part of a build-system change. Prefer fixing the root source in `skill/`, `scripts/`, or `cli/` (or the engine repo for verb behavior), then regenerate artifacts for validation. Stage generated harness artifacts only for release/main-sync or build-system work. Do not edit generated provider files directly unless you are intentionally patching generated output as part of a build-system change. Prefer fixing the root source in `skill/`, `scripts/`, or `cli/`, then regenerate artifacts for validation. Stage generated harness artifacts only for release/main-sync or build-system work.
+70 -97
View File
@@ -6,22 +6,8 @@ There is **one** user-invocable skill, `impeccable`, with **23 commands** undern
- `SKILL.src.md` — frontmatter (with the auto-trigger-optimized description and the `allowed-tools` list), shared design laws, and the **Commands** router table. Provider `SKILL.md` files are generated from this source. - `SKILL.src.md` — frontmatter (with the auto-trigger-optimized description and the `allowed-tools` list), shared design laws, and the **Commands** router table. Provider `SKILL.md` files are generated from this source.
- `reference/` — one `<command>.md` per command (`audit.md`, `polish.md`, `critique.md`, etc.), the shared playbooks the router loads outside the command table (`new-work.md`, `craft-floor.md`, `operate.md`, `routing.md`), and the native platform references (`ios.md`, `android.md`). When a sub-command is matched, the router loads its reference file. - `reference/` — one `<command>.md` per command (`audit.md`, `polish.md`, `critique.md`, etc.), the shared playbooks the router loads outside the command table (`new-work.md`, `craft-floor.md`, `operate.md`, `routing.md`), and the native platform references (`ios.md`, `android.md`). When a sub-command is matched, the router loads its reference file.
- `scripts/command-metadata.json` — single source of truth for each command's description, argument hint, and (eventually) category. Both the build and the engine's `pin` verb read from this. - `scripts/command-metadata.json` — single source of truth for each command's description, argument hint, and (eventually) category. Both the build and `pin.mjs` read from this.
- `scripts/impeccable` (+ `impeccable.cmd`, `VERSION`): the launcher every skill verb goes through. See **Engine binary** below. - `scripts/pin.mjs` — creates/removes lightweight redirect shims so users can have `/audit` as a standalone shortcut that delegates to `/impeccable audit`.
- `impeccable pin` — an engine verb that creates/removes lightweight redirect shims so users can have `/audit` as a standalone shortcut that delegates to `/impeccable audit`.
### Engine binary (the runtime behind every verb)
The skill has no runtime of its own. Every command the skill text runs is `{{scripts_path}}/impeccable <verb>` (Setup step 1 says `impeccable context`; `impeccable.cmd` is the Windows twin for shells without `sh`). `skill/scripts/impeccable` is a POSIX `sh` launcher: it execs `$IMPECCABLE_BIN` if set, else the sibling `scripts/bin/<os>-<arch>/impeccable[.exe]`, else `~/.impeccable/bin/impeccable`, else the version-pinned user cache `~/.impeccable/bin/<VERSION>/`, else `impeccable` on PATH, and as a last resort downloads the pinned version into that cache. It exports `IMPECCABLE_SKILL_DIR` (the skill dir, for `reference/*.md` and `command-metadata.json`) and `IMPECCABLE_SELF` (how the binary spells itself in the commands it prints).
The binary is built from **this repo's Cargo workspace** (`Cargo.toml` at the root, `crates/*`; `cargo build --release -p impeccable`). Its verbs are the old script basenames (`context`, `doctor`, `pin`, `hook`, `hook-before-edit`, `live*`, `detect`, ...) with two aliases: `signals` for context-signals and `hooks` for hook-admin. Its observable behavior is specified in `docs/CLI-CONTRACT.md` and pinned by `tests/oracle/`. **Read `docs/ENGINE.md` before touching `crates/`**: it maps the crates and the browser-bundle flow.
- **The rule engine is in the workspace.** Every `check_*` / `scan_*`, the browser rule adapters and the visual-contrast decisions live in `crates/core`, Apache-2.0 like everything else; `crates/foundation` holds what they are written against (JS semantics, color, the registry, the `Dom` trait, the plain-data input and output types) and `crates/core` re-exports it, so consumers name one crate. `crates/wasm` compiles the same source to WebAssembly for the extension, the live overlay and the site, and `cargo xtask bundle` builds those artifacts. There is no build-time download and no exact toolchain pin: `cargo build --release -p impeccable` works offline on stable.
- **`ENGINE_VERSION`** (repo root) pins the engine release (`engine-v<X>` on this repo's GitHub Releases, built by `.github/workflows/release-engine.yml` when `bun run release:engine` pushes the tag). The build copies it to `skill/scripts/VERSION`, which the launcher reads to name the download and the cache dir; `cli/bin/cli.js` reads the same version from `package.json`'s `optionalDependencies`. Bumping it is a release-time decision, like the other manifest versions.
- **Binaries are never tracked.** `skill/scripts/bin/` and `**/skills/impeccable/scripts/bin/` are gitignored, so the tracked provider dirs and `plugin/` ship launcher-only and users get the binary on first run. `bun run build:release` produces launcher-only zips by default; `IMPECCABLE_BUNDLE_ENGINE=1 bun run build:release` fetches every target (`scripts/fetch-engine.mjs --all --lenient`) and stages `bin/<os-arch>/` into the dist skill copies **after** the root harness dirs and `plugin/` were synced, so `dist/universal.zip` is self-contained for offline installs while git stays clean. Bundling is opt-in because five targets in every provider copy put `universal.zip` near 340 MB, past the 25 MB Cloudflare Pages file cap that `impeccable install` downloads through.
- **Tests get a binary** from `IMPECCABLE_BIN`, then `skill/scripts/bin/<os-arch>/` (`bun run fetch:engine`; `IMPECCABLE_BIN=<local build> bun run fetch:engine` copies a local build there), then `target/release/impeccable` from a plain `cargo build --release -p impeccable`. `tests/lib/engine-bin.mjs` is the one resolver; suites that need the binary skip cleanly without it.
- **The oracle is the behavior gate.** `tests/oracle/` holds goldens recorded from the JS scripts before they left the tree, plus reviewed deltas in `DELTAS.md`; `tests/oracle.test.mjs` replays them against the binary in `bun run test`. New cases are recorded from the binary (`record.mjs --bin`) and reviewed by hand. `tests/oracle/vectors/calls/` is the frozen function-level snapshot; it cannot be regenerated.
- **What stays JavaScript here:** the in-page live-mode JS (`skill/scripts/live-browser*.js`, `modern-screenshot.umd.js`), the build and test tooling, the extension shell, and the npm shim.
**Do not add standalone skills** unless there's a strong reason. The consolidation was deliberate: the `/` menu pollution problem is real and gets worse as users install more plugins. **Do not add standalone skills** unless there's a strong reason. The consolidation was deliberate: the `/` menu pollution problem is real and gets worse as users install more plugins.
@@ -53,36 +39,36 @@ A second axis, **orthogonal to mode**. Mode answers "what does the visitor come
- **android** — a native Android app. Loads `reference/android.md` (Material Design 3 distilled). - **android** — a native Android app. Loads `reference/android.md` (Material Design 3 distilled).
- **adaptive** — a cross-platform app shipping both iOS and Android from one codebase (Flutter, React Native, KMP) that adapts per OS. Loads **both** `reference/ios.md` and `reference/android.md`. A Flutter/RN app that uses one look on both platforms (Material-everywhere is the Flutter default) is not adaptive; it takes that single platform's value. - **adaptive** — a cross-platform app shipping both iOS and Android from one codebase (Flutter, React Native, KMP) that adapts per OS. Loads **both** `reference/ios.md` and `reference/android.md`. A Flutter/RN app that uses one look on both platforms (Material-everywhere is the Flutter default) is not adaptive; it takes that single platform's value.
PRODUCT.md carries a `## Platform` section with a bare value (`web` / `ios` / `android` / `adaptive`). The `context` verb parses it; a **missing field defaults to `web`** so legacy projects are unaffected. A line that names both native targets (e.g. `ios, android`) is also read as `adaptive`; any other unrecognized value falls back to web **and** `impeccable context` prints a WARNING directive naming the bad value, so a toolchain name or typo never silently gets web guidance. `impeccable context` inlines the native reference(s) directly into its output when the value is `ios`, `android`, or `adaptive` (both), so native conventions land in context without a second model-directed read. `init` (Step 3) confirms an ambiguous platform as part of the product-truth interview, and Step 4 records it as the bare value. PRODUCT.md carries a `## Platform` section with a bare value (`web` / `ios` / `android` / `adaptive`). It's parsed by `extractPlatform()` in `skill/scripts/context.mjs`, built on the generic `extractSectionValue()` helper; a **missing field defaults to `web`** so legacy projects are unaffected. A line that names both native targets (e.g. `ios, android`) is also read as `adaptive`; any other unrecognized value falls back to web **and** the `context.mjs` CLI prints a WARNING directive naming the bad value, so a toolchain name or typo never silently gets web guidance. `context.mjs` inlines the native reference(s) directly into its output when the value is `ios`, `android`, or `adaptive` (both), so native conventions land in context without a second model-directed read. `init` (Step 3) confirms an ambiguous platform as part of the product-truth interview, and Step 4 records it as the bare value.
`ios.md` and `android.md` are distilled from the MIT-licensed [ehmo/platform-design-skills](https://github.com/ehmo/platform-design-skills); attribution is in `NOTICE.md`. `ios.md` and `android.md` are distilled from the MIT-licensed [ehmo/platform-design-skills](https://github.com/ehmo/platform-design-skills); attribution is in `NOTICE.md`.
Where a command's native guidance diverges too much to share a file, it gets a **native variant**: `reference/<command>.native.md`, listed in SKILL.md's Commands table and routed **instead of** the web file when `setup.platform` is native (Setup step 2). One variant covers ios, android, and adaptive; per-OS specifics stay in the platform refs, which Setup loads regardless. Variants today: `audit.native.md`, `adapt.native.md` (their web files carry a one-line web-only guard that redirects stray native readers). `audit.native.md` mirrors `audit.md`'s report skeleton; change the skeleton in both together. Commands whose divergence the platform refs already cover (`animate`, `layout`) carry nothing extra; don't add in-file translation notes, they make native runs pay for web content. Where a command's native guidance diverges too much to share a file, it gets a **native variant**: `reference/<command>.native.md`, listed in SKILL.md's Commands table and routed **instead of** the web file when `setup.platform` is native (Setup step 2). One variant covers ios, android, and adaptive; per-OS specifics stay in the platform refs, which Setup loads regardless. Variants today: `audit.native.md`, `adapt.native.md` (their web files carry a one-line web-only guard that redirects stray native readers). `audit.native.md` mirrors `audit.md`'s report skeleton; change the skeleton in both together. Commands whose divergence the platform refs already cover (`animate`, `layout`) carry nothing extra; don't add in-file translation notes, they make native runs pay for web content.
**Live mode, `impeccable detect`, and the design hook are web-only.** They operate on a browser / HTML rules, so SKILL.md's routing skips live and `impeccable detect` for any native (`ios` / `android` / `adaptive`) project, and the `hook` and `hook-before-edit` verbs skip their scan when PRODUCT.md declares a native platform — a React Native project is made of exactly the `.tsx` / `.ts` / `.js` files the hook watches. **Live mode, the `detect` CLI, and the design hook are web-only.** They operate on a browser / HTML rules, so SKILL.md's routing skips live and `detect.mjs` for any native (`ios` / `android` / `adaptive`) project, and the hook (`hook-lib.mjs` `resolveProjectPlatform` / `isNativePlatform`, also used by `hook-before-edit.mjs`) skips its scan when PRODUCT.md declares a native platform — a React Native project is made of exactly the `.tsx` / `.ts` / `.js` files the hook watches.
### Artifact staleness and the doctor pass ### Artifact staleness and the doctor pass
Impeccable writes files into user projects, so a released version has to cope with artifacts an older one wrote. Three kinds of drift travel under "out of date" and they are handled separately: Impeccable writes files into user projects, so a released version has to cope with artifacts an older one wrote. Three kinds of drift travel under "out of date" and they are handled separately:
1. **Tool version drift** (installed skill older than published). Emitted by `impeccable context` as `UPDATE_AVAILABLE`. Predates this system, unchanged. 1. **Tool version drift** (installed skill older than published). `computeUpdateDirective()` in `context.mjs`, emitted as `UPDATE_AVAILABLE`. Predates this system, unchanged.
2. **Schema drift** (an artifact carries fields nothing reads, is missing fields now expected, or sits in a retired location). Deterministic; the engine's staleness module. 2. **Schema drift** (an artifact carries fields nothing reads, is missing fields now expected, or sits in a retired location). Deterministic. `skill/scripts/lib/staleness.mjs`.
3. **Truth drift** (the code moved on and the document no longer describes it). Not mechanical. `document` and `init` own the rewrite; the deep pass measures a proxy and is required to say it is a proxy. 3. **Truth drift** (the code moved on and the document no longer describes it). Not mechanical. `document` and `init` own the rewrite; the deep pass measures a proxy and is required to say it is a proxy.
**Two tiers, and the split is a performance contract, not a preference.** **Two tiers, and the split is a performance contract, not a preference.**
- **Tier 1** runs inside `impeccable context` at boot. It may only spend what a boot already spends: markdown already in memory, a bounded set of stats, and the small JSON files the boot reads regardless. **No directory walks, no git, no cross-workspace sweep.** The one walk it uses is the target-candidate discovery the boot has already paid for. Adding an expensive check here taxes every session in every project. - **Tier 1** is `collectBootFindings()` in `lib/staleness.mjs`, called from `appendStalenessDirective()` in `context.mjs`. It may only spend what a boot already spends: markdown already in memory, a bounded set of stats, and the small JSON files the boot reads regardless. **No directory walks, no git, no cross-workspace sweep.** The one walk it uses (`discoverTargetCandidates`) is one `resolveTargetSelection` has already paid for. Adding an expensive check here taxes every session in every project.
- **Tier 2** is the deep pass behind `impeccable doctor`, run on demand. Git log, per-workspace sweep, ignore-list validation against the live rule registry, hook launcher resolution. - **Tier 2** is `lib/staleness-deep.mjs`, run on demand by `skill/scripts/doctor.mjs`. Git log, per-workspace sweep, ignore-list validation against the live `ANTIPATTERNS` registry, hook script resolution.
**Findings are data.** `{ id, artifact, path, severity, summary, fix }`, so the boot directive, the text report, and `--json` all render one set. Severity says what should happen, not how bad it is: `auto` (fix silently on the next write to that file), `mention` (state once, carry on), `route` (name the command that owns the repair). `doctor --fix` applies only `auto`, and only where no judgment is involved. **Findings are data.** `{ id, artifact, path, severity, summary, fix }`, so the boot directive, the text report, and `--json` all render one set. Severity says what should happen, not how bad it is: `auto` (fix silently on the next write to that file), `mention` (state once, carry on), `route` (name the command that owns the repair). `doctor --fix` applies only `auto`, and only where no judgment is involved.
**Emission discipline.** Boot output is already heavy, so Tier 1 emits **one** `CONTEXT_STALE` directive for the whole set, and `mention` and `route` findings are throttled to once a week per project (cached in `~/.impeccable/staleness-check.json`, alongside the update cache, so no gitignore entry is owed). `auto` findings are never throttled and never shown to the user. Opt out with `"stalenessCheck": false` or `IMPECCABLE_NO_STALENESS_CHECK=1`. **An oracle case that asserts on other boot directives should pin that env var.** **Emission discipline.** Boot output is already heavy, so Tier 1 emits **one** `CONTEXT_STALE` directive for the whole set, and `lib/staleness-notice.mjs` throttles `mention` and `route` findings to once a week per project (cached in `~/.impeccable/staleness-check.json`, alongside the update cache, so no gitignore entry is owed). `auto` findings are never throttled and never shown to the user. Opt out with `"stalenessCheck": false` or `IMPECCABLE_NO_STALENESS_CHECK=1`. **A test that asserts on other boot directives should set that env var**, which is why the update-check suite in `tests/context.test.mjs` does.
**Provenance stamps.** PRODUCT.md carries `<!-- impeccable:product-schema N -->` (schema constants live in the engine; template in `init.md`). Without it, every check is a heuristic reconstruction of what era a file came from. **Stamps are schema versions, not release versions**: a PRODUCT.md written by v4.0.0 is not stale under v4.0.1, and a schema version changes only when the shape does. **DESIGN.md deliberately carries no stamp** because it follows the external design.md spec that Stitch's linter validates, and every DESIGN.md signal (sidecar `schemaVersion`, sidecar mtime, section coverage, git drift) is measurable without one. **Provenance stamps.** PRODUCT.md carries `<!-- impeccable:product-schema N -->` (constants in `lib/artifact-schema.mjs`, template in `init.md`). Without it, every check is a heuristic reconstruction of what era a file came from. **Stamps are schema versions, not release versions**: a PRODUCT.md written by v4.0.0 is not stale under v4.0.1, and a schema version changes only when the shape does. **DESIGN.md deliberately carries no stamp** because it follows the external design.md spec that Stitch's linter validates, and every DESIGN.md signal (sidecar `schemaVersion`, sidecar mtime, section coverage, git drift) is measurable without one.
**When you retire a PRODUCT.md field, add it to the engine's deprecated-sections list** with the reason (and record the new boot output as an oracle case). The reason is not decoration: told only that a field is deprecated, models preserve it "just in case", which is how a retired axis keeps steering current output. **When you retire a PRODUCT.md field, add it to `PRODUCT_DEPRECATED_SECTIONS`** in `lib/artifact-schema.mjs` with the reason. The reason is not decoration: told only that a field is deprecated, models preserve it "just in case", which is how a retired axis keeps steering current output.
**`doctor` is a utility command, not a design command.** It follows the `hooks` and `pin` pattern (a line in SKILL.src.md plus `reference/doctor.md`), not the Commands-table pattern. It is deliberately **not** in `IMPECCABLE_SUB_COMMANDS`, `command-metadata.json`, `SKILL_CATEGORIES`, or the `pin` verb's valid-command list, and it does not count toward the 23. Keep maintenance tooling out of the design menu. **`doctor` is a utility command, not a design command.** It follows the `hooks` and `pin` pattern (a line in SKILL.src.md plus `reference/doctor.md`), not the Commands-table pattern. It is deliberately **not** in `IMPECCABLE_SUB_COMMANDS`, `command-metadata.json`, `SKILL_CATEGORIES`, or `pin.mjs`'s `VALID_COMMANDS`, and it does not count toward the 23. Keep maintenance tooling out of the design menu.
## Repo split: public product vs private service (impeccable-site) ## Repo split: public product vs private service (impeccable-site)
@@ -90,7 +76,7 @@ As of v4 the repo holds only the open-source product layer: the skill, CLI, exte
Consequences here: Consequences here:
- `impeccable concept-seed` has no local catalog. It resolves data via `IMPECCABLE_CATALOG_DIR` (private repo, evals, tests), then the roll API at impeccable.style, then a degraded promotion-only seed. Oracle cases run against `tests/fixtures/concept-catalog/`. - `skill/scripts/concept-seed.mjs` has no local catalog. It resolves data via `IMPECCABLE_CATALOG_DIR` (private repo, evals, tests), then the roll API at impeccable.style, then a degraded promotion-only seed. Tests run against `tests/fixtures/concept-catalog/`.
- The choice-ping telemetry (`--chosen`) honors `DO_NOT_TRACK` and `IMPECCABLE_NO_TELEMETRY` and only fires for API-dealt rolls. - The choice-ping telemetry (`--chosen`) honors `DO_NOT_TRACK` and `IMPECCABLE_NO_TELEMETRY` and only fires for API-dealt rolls.
- Site copy, changelog, theme, and count validation for site pages happen in impeccable-site; this repo's `validateProse` scans only the READMEs. - Site copy, changelog, theme, and count validation for site pages happen in impeccable-site; this repo's `validateProse` scans only the READMEs.
- The release script reads the changelog from `../impeccable-site/site/pages/changelog.astro` when releasing from here. - The release script reads the changelog from `../impeccable-site/site/pages/changelog.astro` when releasing from here.
@@ -104,7 +90,7 @@ The build's `validateProse` step (in `scripts/build.js`) enforces a denylist: em
`validateProse` scans `README.md` and `README.npm.md`; site copy is validated in impeccable-site. `validateProse` scans `README.md` and `README.npm.md`; site copy is validated in impeccable-site.
**`skill/` is checked too, by a second gate.** `validateProse` skips it because the full ruleset does not fit LLM-facing reference instructions. `validateSkillProse` then scans `skill/**/*.md` (markdown only, not the launcher or page JS under `skill/scripts/`) and fails the build on em dashes plus the subset of phrases with no technical reading: `load-bearing`, `highest-leverage`, `biggest unlock`, `reflex defaults`, `collapses into monoculture`, `data-driven`, `delve`, `tapestry`, `in today's`, `gone are the days`, `let's dive in`, `in summary`, `in conclusion`. The words it does *not* enforce in `skill/` (`seamless`, `robust`, `elevate`, and friends) are the ones with legitimate technical uses. Net effect: an em dash in `skill/reference/*.md` fails `bun run build`; an em dash in a `scripts/*.js` code comment does not. **`skill/` is checked too, by a second gate.** `validateProse` skips it because the full ruleset does not fit LLM-facing reference instructions. `validateSkillProse` then scans `skill/**/*.md` (markdown only, not `skill/scripts/**` code or comments) and fails the build on em dashes plus the subset of phrases with no technical reading: `load-bearing`, `highest-leverage`, `biggest unlock`, `reflex defaults`, `collapses into monoculture`, `data-driven`, `delve`, `tapestry`, `in today's`, `gone are the days`, `let's dive in`, `in summary`, `in conclusion`. The words it does *not* enforce in `skill/` (`seamless`, `robust`, `elevate`, and friends) are the ones with legitimate technical uses. Net effect: an em dash in `skill/reference/*.md` fails `bun run build`; an em dash in a `skill/scripts/*.mjs` code comment does not.
The deeper structural issues (negation pivot, triadic auto-pilot, uniform paragraph rhythm, hollow confidence) require human judgment. `docs/STYLE.md` lists them. Use them on every editorial pass. The deeper structural issues (negation pivot, triadic auto-pilot, uniform paragraph rhythm, hollow confidence) require human judgment. `docs/STYLE.md` lists them. Use them on every editorial pass.
@@ -114,14 +100,11 @@ The build system compiles the impeccable skill from `skill/` to provider-specifi
```bash ```bash
bun run build # Build dist/ provider output without syncing root harness dirs bun run build # Build dist/ provider output without syncing root harness dirs
bun run build:release # Build dist/ provider output, sync root harness dirs + plugin/, stage engine binaries into dist zips bun run build:release # Build dist/ provider output and sync root harness dirs + plugin/
bun run rebuild # Clean and rebuild without root harness sync bun run rebuild # Clean and rebuild without root harness sync
bun run rebuild:release # Clean and rebuild with root harness sync bun run rebuild:release # Clean and rebuild with root harness sync
bun run fetch:engine # Download the pinned engine binary for this machine into skill/scripts/bin/
``` ```
The skill's `scripts/` payload is copied verbatim to every provider (launcher with its executable bit, `impeccable.cmd`, `VERSION`, `command-metadata.json`, page JS); nothing under `skill/scripts/bin/` is read as source. The in-page detector bundle and the extension's detector pieces are produced by `cargo xtask bundle`, which `bun run build:extension` runs; the page JS and the bundling itself live in the `impeccable-bundle` library crate (`crates/bundle`) so a downstream rule pack can build the same artifacts for its own wasm module.
Source files use placeholders that get replaced per-provider: Source files use placeholders that get replaced per-provider:
- `{{model}}` — Model name (Claude, Gemini, GPT, etc.) - `{{model}}` — Model name (Claude, Gemini, GPT, etc.)
- `{{config_file}}` — Config file name (CLAUDE.md, .cursorrules, etc.) - `{{config_file}}` — Config file name (CLAUDE.md, .cursorrules, etc.)
@@ -157,25 +140,9 @@ bun run test # Default suite: unit + static framework fixtures
bun run test:live-e2e # Opt-in: full-cycle live-mode E2E across framework fixtures bun run test:live-e2e # Opt-in: full-cycle live-mode E2E across framework fixtures
bun run test:skill-behavior # Opt-in: LLM-backed checks that the skill text actually drives the agent's setup flow bun run test:skill-behavior # Opt-in: LLM-backed checks that the skill text actually drives the agent's setup flow
bun run test:plugin-e2e # Just the plugin loader E2E (also part of the default suite) bun run test:plugin-e2e # Just the plugin loader E2E (also part of the default suite)
bun run test:cleanup # Kill live servers a previous run of THIS checkout left behind
``` ```
Unit tests (build orchestration, transformers, validators) run via `bun test`. Everything that spawns the engine binary (`tests/oracle.test.mjs`, `tests/framework-fixtures.test.mjs`) runs via `node --test`; both skip cleanly when no binary is found (`bun run fetch:engine` or `IMPECCABLE_BIN`). The `test` script handles this split automatically. Verb behavior is not unit-tested here at all: the oracle goldens and the engine repo's own tests own it. Unit tests (build orchestration, detector logic) run via `bun test`. Fixture tests (jsdom-based HTML detection) run via `node --test` because bun is too slow with jsdom. The `test` script handles this split automatically.
### Live servers must not outlive their test process
A live server does not die with the process that started it: a direct child survives its parent, and `impeccable live-server --background` is orphaned to pid 1 by design (`spawn_detached_with_args` in `crates/live/src/server.rs`). Teardown in an `after()` hook or a `finally` covers only the exits JavaScript can observe, so a `SIGKILL`, a Ctrl-C, or a wedged runner used to leave servers squatting the live suite's fixed ports for days (issue #717).
Three pieces keep that from recurring, and a new test that starts a server owes the first one:
- **`armLiveServerReaper()`** (`tests/lib/live-servers.mjs`), called once at module scope by any test file that starts a live server. It stamps the process environment with a unique marker, installs exit and signal handlers, and spawns a detached reaper holding a pipe to the process. When the process dies for any reason at all, the pipe closes and the reaper kills the servers carrying that marker. Wrap direct children in `trackServerChild()` so the common case is a cheap `child.kill()`. On this branch the two places that start one are `tests/live-e2e/session.mjs` and the oracle's daemon steps (`runDaemonStep` in `tests/oracle/lib.mjs`); both already arm it.
The mechanism is deliberately implementation-agnostic, which is what let it survive the Node-to-Rust swap unchanged: it keys on the environment rather than on anything the server implements. That works because the daemon spawn does `env_clear().envs(env)` against `Io::stdio()`'s `env`, which is `std::env::vars()`, so the detached Rust process carries the parent's environment and the markers reach it. If a future change scrubs or narrows that env, the guard goes silently blind, so keep the daemon inheriting it.
- **The runner guard.** `scripts/run-tests.mjs` runs each suite command as its own process-group leader, ends that group on `SIGINT` / `SIGTERM` / `SIGHUP` and on the wall-clock cap, and after every suite checks whether any live server carrying that suite's run id is still alive. If one is, it kills it and fails the run. Bypass with `IMPECCABLE_SKIP_LEAK_CHECK=1`. The same group is what `IMPECCABLE_TEST_WALL_CLOCK_MS` (or a suite's `wallClockMs`) SIGKILLs when a command wedges, so a suite blocked in a synchronous call still ends and still gets swept.
- **`bun run test:cleanup`.** A one-shot sweep for leftovers from earlier runs.
- **`tests/live-server-leak.test.mjs`** pins the guarantee against the real engine binary (resolved through `tests/lib/engine-bin.mjs`, skipped when there is none): it boots `impeccable live-server`, SIGKILLs the process that started it, and fails if the server outlives it.
**Everything that kills is scoped by an environment marker this repo's harness exported**, never by process name, port, or path. A sweep can never touch a live server that another checkout, or the user's own session, is running. Keep it that way, and keep marker values opaque: every one is a random token or a hash of the checkout path (`repoMarker()`), drawn from `[A-Za-z0-9_-]` so it can never contain whitespace. `ps -E` flattens the environment into one whitespace-separated line, so a value free to hold a space could hide the end of its own entry and let one checkout's cleanup reach another's servers. `assertMarkerValue` refuses such a value; the readable path travels separately as `IMPECCABLE_TEST_REPO_PATH`, which nothing matches on.
### Which opt-in suite a change owes ### Which opt-in suite a change owes
@@ -183,15 +150,14 @@ The default suite does not cover everything. When a change touches one of these
| Area touched | Run | Cost | | Area touched | Run | Cost |
|---|---|---| |---|---|---|
| `ENGINE_VERSION` bump, `skill/scripts/live-browser*.js` | `bun run test:live-e2e` | ~2 min, real npm installs + dev servers, needs Playwright Chromium | | `skill/scripts/live-*.{mjs,js}`, `skill/scripts/live/**` | `bun run test:live-e2e` | ~2 min, real npm installs + dev servers, needs Playwright Chromium |
| `ENGINE_VERSION` bump | also `bun run test:live-e2e-accept-cleanup` | bills a provider API key | | `live-accept` / `live-browser` / `live-server` / `live-wrap` / `live/sveltekit-adapter` | also `bun run test:live-e2e-accept-cleanup` | bills a provider API key |
| `ENGINE_VERSION` bump | `bun run test:live-svelte-adapter-deepseek` | bills DeepSeek | | `live/sveltekit-adapter.mjs`, `live/svelte-component.mjs` | `bun run test:live-svelte-adapter-deepseek` | bills DeepSeek |
| `SKILL.src.md` Setup, Setup-adjacent reference files, `ENGINE_VERSION` bump | `bun run test:skill-behavior` | ~5 min, bills all four provider keys | | `SKILL.src.md` Setup, `context.mjs`, Setup-adjacent reference files | `bun run test:skill-behavior` | ~5 min, bills all four provider keys |
| `ENGINE_VERSION` bump | `bun run test:new-work-e2e` | Playwright, offline, no API cost | | `serve-question.mjs`, `generate-image.mjs`, `concept-seed.mjs` | `bun run test:new-work-e2e` | Playwright, offline, no API cost |
| `cli/bin/commands/skills.mjs` | `bun run test:cli-remote-e2e` | hits impeccable.style |
| `plugin/`, `skill/agents/`, `scripts/build.js`, plugin manifest validator | `bun run test:plugin-e2e` | ~1 s; already in the default suite, needs the `claude` CLI | | `plugin/`, `skill/agents/`, `scripts/build.js`, plugin manifest validator | `bun run test:plugin-e2e` | ~1 s; already in the default suite, needs the `claude` CLI |
Verb-level behavior changes happen in the engine repo; the check they owe here is `bun run test` with a binary present (the oracle), and a new oracle case when the contract grows.
**Plugin loader E2E** (`tests/plugin-e2e.test.mjs`, in the default suite): installs the committed `./plugin` subtree into a real Claude Code, sandboxed via `CLAUDE_CONFIG_DIR` in a temp dir, and asserts the component inventory from `claude plugin details`: the skill parses, every `plugin/agents/*.md` is visible, hooks are discovered. This is the only check that catches loader-contract surprises the unit guards can't know about (PR #494 shipped an `agents` manifest key that silently loaded zero agents; `claude plugin validate` never flags plugin-manifest problems). Runs in about a second; skips cleanly when the `claude` CLI is not on PATH. The known contract itself (allowed manifest keys, no `agents` key, trailing-slash `skills` path, source agents shipped) is pinned deterministically by `scripts/lib/validate-plugin-manifest.js`, unit-tested in `tests/validate-plugin-manifest.test.js` and enforced as a `bun run build` gate. Never add a key to the generated plugin manifest without verifying it against a real install and extending `KNOWN_LOADER_KEYS`. **Plugin loader E2E** (`tests/plugin-e2e.test.mjs`, in the default suite): installs the committed `./plugin` subtree into a real Claude Code, sandboxed via `CLAUDE_CONFIG_DIR` in a temp dir, and asserts the component inventory from `claude plugin details`: the skill parses, every `plugin/agents/*.md` is visible, hooks are discovered. This is the only check that catches loader-contract surprises the unit guards can't know about (PR #494 shipped an `agents` manifest key that silently loaded zero agents; `claude plugin validate` never flags plugin-manifest problems). Runs in about a second; skips cleanly when the `claude` CLI is not on PATH. The known contract itself (allowed manifest keys, no `agents` key, trailing-slash `skills` path, source agents shipped) is pinned deterministically by `scripts/lib/validate-plugin-manifest.js`, unit-tested in `tests/validate-plugin-manifest.test.js` and enforced as a `bun run build` gate. Never add a key to the generated plugin manifest without verifying it against a real install and extending `KNOWN_LOADER_KEYS`.
**Important:** `tests/build.test.js` uses `spyOn(transformers, 'transformCursor')` with the named exports from `scripts/lib/transformers/index.js`. Those named exports (`transformCursor`, `transformClaudeCode`, etc.) are kept specifically for test spying, even though `build.js` itself uses `createTransformer + PROVIDERS` directly. **Do not delete them as "dead code"** — I made that mistake once and broke 8 tests. **Important:** `tests/build.test.js` uses `spyOn(transformers, 'transformCursor')` with the named exports from `scripts/lib/transformers/index.js`. Those named exports (`transformCursor`, `transformClaudeCode`, etc.) are kept specifically for test spying, even though `build.js` itself uses `createTransformer + PROVIDERS` directly. **Do not delete them as "dead code"** — I made that mistake once and broke 8 tests.
@@ -208,13 +174,13 @@ IMPECCABLE_E2E_DEBUG=1 bun run test:live-e2e # dump page DOM + de
**One-time setup**: `npx playwright install chromium` (the suite uses a specific Chromium build keyed to the bundled Playwright version). **One-time setup**: `npx playwright install chromium` (the suite uses a specific Chromium build keyed to the bundled Playwright version).
**Kept out of the default `bun run test`** because (a) it does real `npm install` per fixture, (b) it boots framework dev servers, (c) wall time is ~2 minutes, and (d) it requires Playwright's browser cache. Run it locally before shipping changes to the page JS or before bumping `ENGINE_VERSION`. (Its helpers still drive the live verbs by script path; retargeting them at the launcher is pending.) **Kept out of the default `bun run test`** because (a) it does real `npm install` per fixture, (b) it boots framework dev servers, (c) wall time is ~2 minutes, and (d) it requires Playwright's browser cache. Run it locally before shipping changes to anything in `skill/scripts/live-*.{mjs,js}` or `skill/scripts/live/**`.
Three live-mode invariants worth knowing before editing (established by the 2026-07 rewrite, full rationale in `docs/LIVE-REWRITE-PLAN.md`; the implementation is the engine's `live` crate now, the contract is unchanged): Three live-mode invariants worth knowing before editing (established by the 2026-07 rewrite, full rationale in `docs/LIVE-REWRITE-PLAN.md`):
- **Roots.** `impeccable live` resolves appRoot/repoRoot/contextRoot once at boot and persists `.impeccable/live/roots.json`; every live verb re-anchors on that manifest and chdirs onto its appRoot. Never derive a live path from ambient cwd; go through the manifest. - **Roots.** `skill/scripts/live/roots.mjs` resolves appRoot/repoRoot/contextRoot once at boot and persists `.impeccable/live/roots.json`; every live CLI calls `enterLiveRoot()` in its main guard and chdirs onto the manifest's appRoot. Never derive a live path from ambient cwd in a new script; go through the manifest.
- **Svelte preview modules must live under `node_modules/.impeccable-live`.** SvelteKit restricts vite `server.fs.allow` to src/lib, src/routes, .svelte-kit, and node_modules; a preview tree under `.impeccable/` 403s. Staleness is handled by per-publish revision dirs (`r<N>/`, bumped by the server on every done-reply), not by file watching. - **Svelte preview modules must live under `node_modules/.impeccable-live`.** SvelteKit restricts vite `server.fs.allow` to src/lib, src/routes, .svelte-kit, and node_modules; a preview tree under `.impeccable/` 403s. Staleness is handled by per-publish revision dirs (`r<N>/`, bumped by the server on every done-reply), not by file watching.
- **`svelte` is a devDependency for tests only.** The Svelte scaffolder and accept pipeline resolve the compiler from the USER app's node_modules at runtime; the fixture sweep and oracle cases symlink this repo's copy into staged fixtures. - **`svelte` is a devDependency for tests only.** The AST scaffolder (`live/svelte-ast.mjs`) and accept pipeline (`live/accept-css.mjs`) resolve the compiler from the USER app's node_modules at runtime; unit tests and the static fixture sweep symlink this repo's copy into staged fixtures. Skill scripts still ship dependency-free.
The agent is pluggable via a one-method interface in `tests/live-e2e/agent.mjs`: `generateVariants(event, context) → { scopedCss, variants[] }`. The default fake agent emits canned variants that exercise all three param kinds (`range`, `steps`, `toggle`). The orchestrator (wrap, write, accept, carbonize) is agent-agnostic. The agent is pluggable via a one-method interface in `tests/live-e2e/agent.mjs`: `generateVariants(event, context) → { scopedCss, variants[] }`. The default fake agent emits canned variants that exercise all three param kinds (`range`, `steps`, `toggle`). The orchestrator (wrap, write, accept, carbonize) is agent-agnostic.
@@ -242,33 +208,37 @@ IMPECCABLE_SKILL_BEHAVIOR_VERBOSE=1 bun run test:skill-behavior # dump per-sc
**Adding a scenario.** Write the fixture in `tests/skill-behavior/fixtures.mjs`, add the `it()` block in `scenarios.test.mjs` (the harness uses the source `skill/` dir via a symlink, so no rebuild needed), and update the baseline table in the suite's README. The harness's `fileLoaded(trace, filename)` helper checks both `read` and bash `cat` — different models prefer different tools. **Adding a scenario.** Write the fixture in `tests/skill-behavior/fixtures.mjs`, add the `it()` block in `scenarios.test.mjs` (the harness uses the source `skill/` dir via a symlink, so no rebuild needed), and update the baseline table in the suite's README. The harness's `fileLoaded(trace, filename)` helper checks both `read` and bash `cat` — different models prefer different tools.
**The harness symlinks source, not built output.** This is deliberate so SKILL.md / reference edits show up immediately without `bun run build:skills`; the launcher under `skill/scripts/` resolves the binary the same way tests do. The trade-off: reference files surface their raw `{{placeholders}}`, but the assertions key on tool calls rather than content, so it doesn't matter for correctness. **The harness symlinks source, not built output.** This is deliberate so SKILL.md / reference / `scripts/context.mjs` edits show up immediately without `bun run build:skills`. The trade-off: reference files surface their raw `{{placeholders}}`, but the assertions key on tool calls rather than content, so it doesn't matter for correctness.
## CLI ## CLI
`cli/` is the npm package `impeccable`, now a thin shim: `cli/bin/cli.js` locates the engine binary (`IMPECCABLE_BIN`, then the `@impeccable/cli-<os>-<arch>` optional dependency pinned at `ENGINE_VERSION`, then `~/.impeccable/bin/<version>/`, then a checksum-verified download into that cache) and execs it with argv. The verbs users see (`detect`, `ignores`, `install`, `update`, `check`, `link`, `help`, the legacy `skills` namespace) are the binary's. `cli/platform-packages/<os>-<arch>/package.json` are the templates the engine release publishes; the version pinned in `package.json` `optionalDependencies` must equal `ENGINE_VERSION`. The CLI lives in this repo under `cli/`: `cli/bin/` (entry + sub-commands), `cli/engine/` (the detect-antipatterns rule engine + browser variant), `cli/lib/` (helpers shared by CLI and Cloudflare Pages Functions). Published to npm as `impeccable`.
```bash ```bash
npx impeccable detect [file-or-dir-or-url...] # detect anti-patterns npx impeccable detect [file-or-dir-or-url...] # detect anti-patterns
npx impeccable detect --json src/ # JSON output npx impeccable detect --fast --json src/ # regex-only, JSON output
npx impeccable install # install skills npx impeccable live # start browser overlay server
npx impeccable --help # show help npx impeccable skills install # install skills
npx impeccable --help # show help
``` ```
The package no longer exports a JS detector API (`main` / `exports` are gone); the in-page bundle for the extension and site comes from the engine repo. The browser detector (`cli/engine/detect-antipatterns-browser.js`) is generated from the main engine. After changing `cli/engine/detect-antipatterns.mjs`, rebuild it:
```bash
bun run build:browser
```
**IMPORTANT**: Always use `node` (not `bun`) to run the detect CLI. Bun's jsdom implementation is extremely slow and will cause scans with HTML files to hang for minutes.
## Versioning ## Versioning
**Feature PRs do not bump versions and do not add changelog entries.** Bumping is a release step, not part of the change that earns the release: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened. Land the code first; the maintainer bumps and writes the changelog when cutting the release. This holds even though the "Bump when: ..." notes below name the source dirs — those say *which* component a change belongs to, not *when* to edit the manifest. The only PR that touches a manifest version is one whose purpose is the release itself. **Feature PRs do not bump versions and do not add changelog entries.** Bumping is a release step, not part of the change that earns the release: a version in a feature branch conflicts with every other open branch, and a changelog entry describes a release that has not happened. Land the code first; the maintainer bumps and writes the changelog when cutting the release. This holds even though the "Bump when: ..." notes below name the source dirs — those say *which* component a change belongs to, not *when* to edit the manifest. The only PR that touches a manifest version is one whose purpose is the release itself.
There are three independently versioned components plus the engine pin. Only bump the one(s) that actually changed: There are three independently versioned components. Only bump the one(s) that actually changed:
**Engine pin** (`ENGINE_VERSION`, root):
- The engine release the launcher downloads and the npm shim's `optionalDependencies` pin. Bump it when a new engine release is published; keep `package.json` `optionalDependencies` at the same version and run `bun run build` (it rewrites `skill/scripts/VERSION`). A skill release that needs the new engine bumps this together with the skill version.
**CLI** (npm package): **CLI** (npm package):
- `package.json``version` - `package.json``version`
- Bump when: CLI shim code changes (`cli/bin/cli.js`, `cli/platform-packages/`) - Bump when: CLI code changes (`cli/bin/`, `cli/engine/detect-antipatterns.mjs`, etc.)
**Skills** (Claude Code plugin / skill definitions): **Skills** (Claude Code plugin / skill definitions):
- `.claude-plugin/plugin.json``version` (source of truth) - `.claude-plugin/plugin.json``version` (source of truth)
@@ -278,7 +248,7 @@ There are three independently versioned components plus the engine pin. Only bum
**Chrome extension**: **Chrome extension**:
- `extension/manifest.json``version` - `extension/manifest.json``version`
- Bump when: extension code changes (`extension/`), or a rule change alters what the shipped bundle detects. The extension runs the rules as WebAssembly in an offscreen document; `extension/detector/` is built at package time by `cargo xtask bundle` and is not tracked, so an extension release always needs `bun run build:extension` (and therefore a Rust toolchain plus `wasm-pack`) before the zip is attached. - Bump when: extension code changes (`extension/`)
**Website changelog** (`site/pages/changelog.astro` in the private impeccable-site repo): **Website changelog** (`site/pages/changelog.astro` in the private impeccable-site repo):
- Add a new `<article>` entry at the top of the relevant component's group, and move the `cf-entry--current` class + `Current` badge onto it (off the previous newest skill entry). The component is derived from the entry `id` prefix: `cli-*`, `ext-*`, else skill. - Add a new `<article>` entry at the top of the relevant component's group, and move the `cf-entry--current` class + `Current` badge onto it (off the previous newest skill entry). The component is derived from the entry `id` prefix: `cli-*`, `ext-*`, else skill.
@@ -305,16 +275,6 @@ Skill releases attach `dist/universal.zip`. Extension releases run `bun run buil
If you need to fix release notes after the fact (typo, missing thank-you, formatting bug): `gh release edit <tag> --notes-file <md>`. The release script's `htmlToMarkdown` function is the cleanest source for regenerating notes from the changelog. If you need to fix release notes after the fact (typo, missing thank-you, formatting bug): `gh release edit <tag> --notes-file <md>`. The release script's `htmlToMarkdown` function is the cleanest source for regenerating notes from the changelog.
### Release order is mechanically enforced (triage decision D4)
The skill launcher, the npm shim (`cli/bin/cli.js`), and `impeccable install` all resolve the engine binary for the pinned `ENGINE_VERSION`. Nothing they do works until the engine release exists first. **The order is: publish the engine release, then the platform packages, then release/merge the skill (or CLI):**
1. Publish engine `engine-v<ENGINE_VERSION>`: `bun run release:engine` tags and pushes; `release-engine.yml` builds the five `impeccable-<os>-<arch>[.exe]` binaries plus a `.sha256` beside each and publishes the release on this repo. The whole workspace builds from source, so nothing has to ship ahead of it.
2. Publish the five `@impeccable/cli-<os>-<arch>@<ENGINE_VERSION>` npm platform packages.
3. Only then tag/publish the skill or CLI release, and only then merge a branch that bumps `ENGINE_VERSION` (the `sync-generated-output.yml` workflow rewrites provider dirs on merge to `main`).
`scripts/check-engine-release.mjs` verifies step 1 and 2 for the pinned version (ranged-GET each release asset, registry-probe each npm package; honors `IMPECCABLE_DOWNLOAD_BASE`). It exits non-zero and names exactly which assets are missing. `scripts/release.mjs` runs it as a hard gate before tagging the **skill** and **CLI** components and refuses to proceed when any asset is absent; the **extension** release is exempt because it ships a vendored WASM detector and never execs the engine. `IMPECCABLE_SKIP_ENGINE_CHECK=1` bypasses the gate only for the case where the assets exist but the registry probe is unreachable. CI's `engine-release-ready` job runs the same script; it is `continue-on-error: true` with a loud `::warning` until the first engine release is published, at which point flip it to `false` so a mis-ordered merge fails CI.
## Adding New Commands ## Adding New Commands
All commands live under `/impeccable`. To add a new one: All commands live under `/impeccable`. To add a new one:
@@ -323,7 +283,7 @@ All commands live under `/impeccable`. To add a new one:
2. Add a row to the **Sub-command reference table** in `skill/SKILL.src.md` 2. Add a row to the **Sub-command reference table** in `skill/SKILL.src.md`
3. Add an entry to the **Command menu** section in the same file 3. Add an entry to the **Command menu** section in the same file
4. Add the command name to `IMPECCABLE_SUB_COMMANDS` in `scripts/lib/utils.js` 4. Add the command name to `IMPECCABLE_SUB_COMMANDS` in `scripts/lib/utils.js`
5. Add it to the `pin` verb's valid-command list (`crates/context`) and record the pin/unpin oracle case 5. Add it to `VALID_COMMANDS` in `skill/scripts/pin.mjs`
6. Add its metadata (description + argumentHint) to `skill/scripts/command-metadata.json` 6. Add its metadata (description + argumentHint) to `skill/scripts/command-metadata.json`
7. Add its category to `SKILL_CATEGORIES` in `scripts/lib/skill-categories.js` 7. Add its category to `SKILL_CATEGORIES` in `scripts/lib/skill-categories.js`
8. Add its relationships to `COMMAND_RELATIONSHIPS` in impeccable-site's `sub-pages-data.js` 8. Add its relationships to `COMMAND_RELATIONSHIPS` in impeccable-site's `sub-pages-data.js`
@@ -341,26 +301,39 @@ The build validator (`generateCounts` in `scripts/build.js`) checks these files
## Adding or modifying anti-pattern detection rules ## Adding or modifying anti-pattern detection rules
The rule logic lives in `crates/core`: every check, the browser rule adapters over the `Dom` trait, and the visual-contrast decisions. `crates/wasm` compiles the same source for the extension, the live overlay and the site. Everything a rule change touches: `cli/engine/detect-antipatterns.mjs` is the source of truth for the rule engine. It powers the CLI, the public-site overlay, the Chrome extension, and the homepage rule count. Five places stay in sync:
| Where | What it is | | Where | How it stays in sync |
|---|---| |---|---|
| `docs/CLI-CONTRACT.md` | Hand-edited: the observable contract of `impeccable detect` and every other verb | | `cli/engine/detect-antipatterns.mjs` (`ANTIPATTERNS` array + `checkXxx` logic) | Hand-edited |
| `crates/foundation` | What checks are written against: the rule registry (`registry.rs`, also published as `antipatterns.json`), findings, color, the `Dom` trait, `SnapshotDom`, and the plain-data input and output types | | `cli/engine/detect-antipatterns-browser.js` | `bun run build:browser` |
| `crates/core` | The checks themselves, plus the re-exports that let consumers name one crate | | `extension/detector/detect.js` + `extension/detector/antipatterns.json` | `bun run build:extension` |
| `crates/html`, `crates/browser`, `crates/detect` | The engines: parsing, cascade, CDP, snapshots, file walking, output. They call the checks through `impeccable_core::checks::*` and `impeccable_core::browser::*` | | impeccable-site `site/public/js/generated/counts.js` | its own build |
| `tests/fixtures/antipatterns/{rule-id}.html` | Hand-edited fixture (two columns, should-flag / should-pass, unique headings, explicit pixel dimensions) |
| `tests/oracle/golden/*` | Recorded from the binary with `node tests/oracle/record.mjs --bin detect-`, reviewed by hand |
| `tests/oracle/vectors/calls/` | Frozen function-level vectors; replayed by `crates/core/tests/vectors.rs` through `impeccable_core::vectors::call` |
| `crates/live/assets/detect-antipatterns-browser.js` | The in-page bundle, a tracked generated file. `cargo xtask bundle` rewrites it; the binary embeds it and serves it as `/detect.js` |
| `extension/detector/` | The five generated pieces (`core.js`, `core_bg.wasm`, `snapshot.js`, `overlay.js`, `antipatterns.json`) written by `cargo xtask bundle`, which `bun run build:extension` runs. Gitignored, never tracked; the build's rule-count check reads `antipatterns.json` when present |
| `skill/SKILL.src.md` and `reference/*.md` | Hand-edited if the rule introduces new design guidance | | `skill/SKILL.src.md` and `reference/*.md` | Hand-edited if the rule introduces new design guidance |
Order for a new rule: fixture here first, registry row in `crates/foundation/src/registry.rs`, the check in `crates/core` against that fixture, oracle case + golden, `cargo xtask bundle` to refresh the tracked live asset, then `bun run build && bun run test` with a binary present. Rule counts quoted in `README.md` / `README.npm.md` are validated by `generateCounts` against the vendored registry. Always run all three builds and the test suite after a rule change:
### Rule packs (downstream crates adding rules) ```bash
bun run build && bun run build:browser && bun run build:extension && bun run test
```
A crate that depends on this workspace can add rules without forking it: implement `impeccable_core::rule_pack::RulePack` (text plus the two browser DOM hooks) and, for the static engine, `impeccable_html::StaticRulePack`, call `impeccable_core::rule_pack::install(&PACK)` at startup, and hand the pack to the engine through `TextOptions` / `ScanOptions`, `DetectHtmlOptions`, `StaticHtmlEngine`, or `BrowserConfig`. Every hook runs after the built-ins and before inline ignores, so built-in output with no pack installed is byte-identical, which the oracle enforces. The registry keeps `ANTIPATTERNS` as the built-in list and `registry::extend` appends a pack's rows, panicking on an id collision. `crates/wasm --features detect` exposes the two file engines as JSON exports (`detect_text_json`, `detect_html_source_json`) for hosts that cannot exec the binary; Pristine consumes that path. Full contract in `docs/ENGINE.md` ("Rule packs"). The shipped `impeccable` binary installs no pack, and nothing in this repo should start doing so. ### TDD order (non-negotiable)
1. **Fixture** at `tests/fixtures/antipatterns/{rule-id}.html` with two columns (should-flag / should-pass), each case identified by a unique heading. Cover ≥4 flag cases and ≥5 false-positive shapes. Use **explicit pixel dimensions in CSS** because jsdom does no layout.
2. **Failing test** in `tests/detect-antipatterns-fixtures.test.mjs` using the snippet-substring pattern (regex `/"([^"]+)"/` against `SHOULD_FLAG` / `SHOULD_PASS` lists). Run it and watch it fail before implementing.
3. **Rule entry** in the `ANTIPATTERNS` array: `id`, `category` (`slop` for AI tells, `quality` for real design or a11y issues), `name`, `description`, optional `skillSection` and `skillGuideline`.
4. **Pure check function** `checkXxx(opts)` returning `[{ id, snippet }]`. No DOM access in the pure function.
5. **Two adapters**: `checkElementXxxDOM(el)` for the browser (`getComputedStyle` + `getBoundingClientRect`) and `checkElementXxx(el, tag, window)` for jsdom (`parseFloat(style.width)` instead of layout). `cli/engine/detect-antipatterns.mjs` is now a thin facade over `cli/engine/{registry,rules,engines,shared}`: the registry entry goes in `registry/antipatterns.mjs`, the pure check + adapters in `rules/checks.mjs`, and the wiring into **both** element loops in `engines/static-html/detect-html.mjs` (jsdom) and `browser/injected/index.mjs` (concatenated into the browser bundle). Forgetting one loop is the most common mistake; symptom is "test passes, live page silent" or vice versa.
6. **Verify on a live page**: `http://localhost:4321/fixtures/antipatterns/{rule-id}.html` and the homepage (no false positives). The two adapter paths can disagree, so manual browser checks catch what the fixture test can't.
### Conventions and jsdom gotchas
- **Snippet format**: wrap the identifying heading text in straight double quotes (e.g. `'icon tile above h3 "Lightning Fast"'`) so the fixture test can extract it. For rules not anchored to a heading, pick another stable identifier.
- **jsdom doesn't lay out**: `getBoundingClientRect()` returns 0×0. Read `parseFloat(style.width)` and `parseFloat(style.height)` from explicit CSS instead.
- **`background:` shorthand isn't decomposed in jsdom**: use the existing `resolveBackground()` and `resolveGradientStops()` helpers (in `engines/static-html/detect-html.mjs`).
- **Computed colors aren't normalized in jsdom**: `parseGradientColors()` handles both hex and rgb forms.
Reference rules to copy from (all in `cli/engine/rules/checks.mjs`): `side-tab` (border), `low-contrast` (color + gradient), `icon-tile-stack` (sibling relationship), `flat-type-hierarchy` (page-level), `kicker-above-heading` (heading-anchored with rule-ownership stand-down).
## Evals Framework (separate private repo) ## Evals Framework (separate private repo)
Generated
-1844
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
# The impeccable runtime: one Cargo workspace next to the skill it powers.
# `cargo build --release -p impeccable` produces the engine binary the launcher
# (skill/scripts/impeccable) runs. See docs/ENGINE.md.
[workspace]
resolver = "2"
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
publish = false
[workspace.dependencies]
impeccable-foundation = { path = "crates/foundation" }
impeccable-common = { path = "crates/common" }
impeccable-core = { path = "crates/core" }
impeccable-detect = { path = "crates/detect" }
impeccable-html = { path = "crates/html" }
impeccable-browser = { path = "crates/browser" }
impeccable-live = { path = "crates/live" }
impeccable-context = { path = "crates/context" }
impeccable-hook = { path = "crates/hook" }
impeccable-comp = { path = "crates/comp" }
impeccable-comp-verbs = { path = "crates/comp-verbs" }
impeccable-bundle = { path = "crates/bundle" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
thiserror = "2"
regex = "1"
once_cell = "1"
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = true
panic = "abort"
-1
View File
@@ -1 +0,0 @@
0.1.0

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