Compare commits

..
Author SHA1 Message Date
Paul Bakaus 53cb5c8cf7 Centralize live path glob matching
AI-assisted change prepared by Codex under scheduled architecture-simplification authorization from maintainer pbakaus.
2026-08-21 12:23:55 -07:00
105 changed files with 2816 additions and 1913 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `$impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+2 -2
View File
@@ -1,9 +1,9 @@
{
"description": "Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.",
"description": "Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.",
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
+2 -2
View File
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
+3 -11
View File
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+8 -38
View File
@@ -699,7 +699,7 @@ function deduplicateProviders(root, providers, scope) {
* SKILL.md, so script-only fixes and removed files are detected.
* Returns true if every bundle skill matches the local copy.
*/
function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) {
function isUpToDate(root, providers, bundleDir, scope) {
const unique = deduplicateProviders(root, providers, scope);
if (unique.length === 0) return false;
@@ -724,8 +724,6 @@ function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) {
if (bundleHash !== localHash) return false;
}
}
if (!providerAgentsUpToDate(bundleDir, root, provider, agentScope)) return false;
}
return true;
}
@@ -747,8 +745,7 @@ async function check() {
console.log('Checking for updates...\n');
try {
const bundleDir = await downloadAndExtractBundle();
const agentScope = isHomeDir(root) ? 'user' : undefined;
const upToDate = isUpToDate(root, providers, bundleDir, undefined, agentScope);
const upToDate = isUpToDate(root, providers, bundleDir);
rmSync(bundleDir, { recursive: true, force: true });
if (upToDate) {
@@ -1254,9 +1251,7 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
}
// Native subagent definitions that ship in the bundle next to a provider's
// skills. Claude Code's live at `.claude/agents/impeccable-*.md`; project
// agents take precedence over user agents. GitHub Copilot's live at
// `.github/agents/impeccable-*.agent.md`:
// skills. GitHub Copilot's live at `.github/agents/impeccable-*.agent.md`:
// project installs commit them at `<repo>/.github/agents/`, user-level
// installs go to `~/.copilot/agents/` (Copilot's user-scope dir, NOT
// `~/.github/`). On a name conflict Copilot lets the user-level file shadow
@@ -1266,11 +1261,6 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
// `~/.cursor/agents/`; project agents take precedence there, so no shadow
// warning is needed.
const PROVIDER_AGENT_ARTIFACTS = {
'.claude': {
ext: '.md',
userDir: home => join(home, '.claude', 'agents'),
userShadowsProject: false,
},
'.github': {
ext: '.agent.md',
userDir: home => join(home, '.copilot', 'agents'),
@@ -1283,23 +1273,6 @@ const PROVIDER_AGENT_ARTIFACTS = {
},
};
function providerAgentsUpToDate(bundleDir, root, provider, scope) {
const artifact = PROVIDER_AGENT_ARTIFACTS[provider];
if (!artifact) return true;
const srcDir = join(bundleDir, provider, 'agents');
if (!existsSync(srcDir)) return true;
const destDir = scope === 'user'
? artifact.userDir(root)
: join(root, provider, 'agents');
const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext));
return agentFiles.every(name => {
const localPath = join(destDir, name);
return existsSync(localPath)
&& hashSkillFile(join(srcDir, name)) === hashSkillFile(localPath);
});
}
function copyProviderAgents(bundleDir, root, providers, { scope, home = homedir() } = {}) {
const targets = Array.isArray(providers) ? providers : [providers];
const results = [];
@@ -1561,8 +1534,7 @@ function hookInstalledForProvider(root, provider) {
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
const normalized = value.replace(/\\/g, '/');
return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => normalized.includes(marker));
return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') {
@@ -2100,9 +2072,7 @@ function resolveUpdateTarget({ projectRoot, home, explicitScope }) {
const homeRooted = isHomeDir(projectRoot);
if (homeRooted && !explicitScope) {
const providers = findInstalledProviders(home);
return providers.length
? { root: home, scope: undefined, agentScope: 'user', providers, scopeLabel: 'user level' }
: null;
return providers.length ? { root: home, scope: undefined, providers, scopeLabel: 'user level' } : null;
}
const projectProviders = homeRooted ? [] : findImpeccableProviders(projectRoot, 'project');
@@ -2233,7 +2203,7 @@ async function update(flags = []) {
: { root: projectRoot, scope: 'project', providers: target.projectProviders, scopeLabel: 'this project' };
}
const { root, scope, agentScope = scope } = target;
const { root, scope } = target;
console.log(`Updating the ${target.scopeLabel} install: ${formatPathForDisplay(root)} (${target.providers.join(', ')})`);
const providers = target.providers;
const linkedProviders = findLinkedProviders(root, providers, scope);
@@ -2257,7 +2227,7 @@ async function update(flags = []) {
}
// Compare local vs remote -- skip if already up to date
if (isUpToDate(root, copyProviders, tmpDir, scope, agentScope)) {
if (isUpToDate(root, copyProviders, tmpDir, scope)) {
try {
const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
@@ -2293,7 +2263,7 @@ async function update(flags = []) {
if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).');
const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope);
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope: agentScope }));
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope }));
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
+4 -4
View File
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
+5 -5
View File
@@ -3986,7 +3986,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7281,7 +7281,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7343,7 +7343,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7640,7 +7640,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
const bg = parseRgb(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -7770,7 +7770,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
+1 -1
View File
@@ -2752,7 +2752,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
+1 -1
View File
@@ -2,7 +2,7 @@
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+3 -3
View File
@@ -137,9 +137,9 @@ const GROK_PROJECT_HOOK = '.grok/skills/impeccable/scripts/hook.mjs';
export function buildClaudeSettingsManifest() {
return {
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: buildClaudeCompatibleHooks(
'Edit|Write',
'Edit|Write|MultiEdit',
CLAUDE_PROJECT_HOOK,
SYSTEM_MESSAGE_NOTICE,
),
@@ -155,7 +155,7 @@ export function buildClaudeSettingsManifest() {
export function buildClaudePluginHooksManifest() {
return {
hooks: buildClaudeCompatibleHooks(
'Edit|Write',
'Edit|Write|MultiEdit',
CLAUDE_PLUGIN_HOOK,
SYSTEM_MESSAGE_NOTICE,
),
+151 -107
View File
@@ -12,8 +12,6 @@ import { homedir, tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs } from './lib/cli-args.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const prRoot = resolve(__dirname, '..');
const defaultBundle = join(prRoot, 'dist', 'universal.zip');
@@ -34,60 +32,31 @@ if (args.help || args.h || !args.repo) {
process.exit(1);
}
const targetRepo = resolve(legacySmokeArg(args.repo));
const bundlePath = resolve(legacySmokeArg(args.bundle) || defaultBundle);
const selectedProviders = (legacySmokeArg(args.providers) || defaultProviders.join(','))
const targetRepo = resolve(args.repo);
const bundlePath = resolve(args.bundle || defaultBundle);
const selectedProviders = (args.providers || defaultProviders.join(','))
.split(',')
.map((provider) => provider.trim().toLowerCase())
.filter(Boolean);
const smokeDir = join(targetRepo, '.impeccable', 'provider-smoke');
const summaryPath = join(smokeDir, 'summary.json');
const directSmokeFile = 'src/__impeccable_provider_smoke_direct.html';
const providerSmoke = {
claude: {
fixture: 'src/__impeccable_provider_smoke_claude.html',
confirmedFixture: 'src/__impeccable_provider_smoke_confirmed_claude.html',
agentChoiceFixture: 'src/__impeccable_provider_smoke_font_choice_claude.html',
admin: '.claude/skills/impeccable/scripts/hook-admin.mjs',
hook: '.claude/skills/impeccable/scripts/hook.mjs',
event: (file) => postToolUseEvent('confirmed-claude', file, 'Edit'),
},
codex: {
fixture: 'src/__impeccable_provider_smoke_codex.html',
confirmedFixture: 'src/__impeccable_provider_smoke_confirmed_codex.html',
agentChoiceFixture: 'src/__impeccable_provider_smoke_font_choice_codex.html',
admin: '.agents/skills/impeccable/scripts/hook-admin.mjs',
hook: '.agents/skills/impeccable/scripts/hook.mjs',
event: (file) => postToolUseEvent('confirmed-codex', file, 'apply_patch'),
},
cursor: {
fixture: 'src/__impeccable_provider_smoke_cursor.html',
confirmedFixture: 'src/__impeccable_provider_smoke_confirmed_cursor.html',
agentChoiceFixture: 'src/__impeccable_provider_smoke_font_choice_cursor.html',
admin: '.cursor/skills/impeccable/scripts/hook-admin.mjs',
hook: '.cursor/skills/impeccable/scripts/hook-before-edit.mjs',
event: (file) => ({
hook_event_name: 'preToolUse',
cwd: targetRepo,
tool_name: 'Write',
tool_input: {
file_path: file,
content: readFileSync(file, 'utf8'),
},
}),
},
const smokeFiles = {
direct: 'src/__impeccable_provider_smoke_direct.html',
claude: 'src/__impeccable_provider_smoke_claude.html',
codex: 'src/__impeccable_provider_smoke_codex.html',
cursor: 'src/__impeccable_provider_smoke_cursor.html',
confirmedClaude: 'src/__impeccable_provider_smoke_confirmed_claude.html',
confirmedCodex: 'src/__impeccable_provider_smoke_confirmed_codex.html',
confirmedCursor: 'src/__impeccable_provider_smoke_confirmed_cursor.html',
agentChoiceClaude: 'src/__impeccable_provider_smoke_font_choice_claude.html',
agentChoiceCodex: 'src/__impeccable_provider_smoke_font_choice_codex.html',
agentChoiceCursor: 'src/__impeccable_provider_smoke_font_choice_cursor.html',
};
const results = [];
const hookConfigFiles = ['.impeccable/config.json', '.impeccable/config.local.json'];
const originalHookConfigFiles = new Map();
function legacySmokeArg(value) {
// The retired local parser represented a bare flag as the string "true".
// Preserve that CLI/error behavior while sharing the repository parser.
return value === true ? 'true' : value;
}
main().catch((error) => {
restoreHookConfigFiles();
if (!results.some((result) => !result.pass)) {
@@ -140,6 +109,21 @@ async function checked(name, classification, fn) {
}
}
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (!arg.startsWith('--')) continue;
const eq = arg.indexOf('=');
if (eq !== -1) {
out[arg.slice(2, eq)] = arg.slice(eq + 1);
} else {
out[arg.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
}
}
return out;
}
function assertPath(path, label) {
if (!existsSync(path)) throw new Error(`${label} does not exist: ${path}`);
}
@@ -482,7 +466,7 @@ function assertNoPluginInstall() {
function runDirectContractChecks() {
clearRuntimeState();
const file = writeBadFixture(directSmokeFile);
const file = writeBadFixture(smokeFiles.direct);
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'direct.ndjson') };
const claude = run('node', ['.claude/skills/impeccable/scripts/hook.mjs'], {
cwd: targetRepo,
@@ -511,7 +495,7 @@ function runDirectContractChecks() {
cwd: targetRepo,
tool_name: 'Write',
tool_input: {
file_path: join(targetRepo, directSmokeFile),
file_path: join(targetRepo, smokeFiles.direct),
content: badFixtureContent(),
},
}),
@@ -531,7 +515,7 @@ function runConfirmedExceptionPersistenceChecks() {
function runConfirmedExceptionForProvider(provider) {
clearRuntimeState();
const rel = providerSmoke[provider].confirmedFixture;
const rel = confirmedSmokeFile(provider);
const file = writeConfirmedFixture(rel);
const beforeLog = `${provider}-confirmed-before.ndjson`;
const afterLog = `${provider}-confirmed-after.ndjson`;
@@ -544,7 +528,7 @@ function runConfirmedExceptionForProvider(provider) {
assertNoSpecificFontIgnoreConfig(provider);
run('node', [
providerSmoke[provider].admin,
providerAdminScript(provider),
'ignore-value',
'overused-font',
'Roboto',
@@ -596,7 +580,7 @@ function runAgentChosenFontExceptionChecks() {
function runAgentChosenFontExceptionForProvider(provider) {
clearRuntimeState();
const rel = providerSmoke[provider].agentChoiceFixture;
const rel = agentChoiceSmokeFile(provider);
const file = writeConfirmedFixture(rel);
const beforeLog = `${provider}-agent-choice-before.ndjson`;
const afterLog = `${provider}-agent-choice-after.ndjson`;
@@ -628,40 +612,27 @@ function runAgentChosenFontExceptionForProvider(provider) {
}
function runProviderAgentFontException(provider, rel) {
runProviderAgent(provider, fontExceptionPrompt(provider, rel), {
logName: `${provider}-agent-choice.log`,
claudeDebugLog: 'claude-agent-choice-debug.log',
});
}
function runProviderAgent(provider, prompt, {
logName,
env = {},
claudeDebugLog,
claudeTools = 'Read,Bash',
claudeAllowedTools = 'Read Bash',
cursorReady = false,
} = {}) {
const prompt = fontExceptionPrompt(provider, rel);
if (provider === 'claude') {
return run('claude', [
run('claude', [
'-p',
'--setting-sources', 'project',
'--permission-mode', 'acceptEdits',
'--tools', claudeTools,
'--allowedTools', claudeAllowedTools,
'--tools', 'Read,Bash',
'--allowedTools', 'Read Bash',
'--debug', 'hooks',
'--debug-file', join(smokeDir, claudeDebugLog),
'--debug-file', join(smokeDir, 'claude-agent-choice-debug.log'),
prompt,
], {
cwd: targetRepo,
env,
logName,
logName: 'claude-agent-choice.log',
timeoutMs: 10 * 60 * 1000,
});
return;
}
if (provider === 'codex') {
return run('codex', [
run('codex', [
'exec',
'-C', targetRepo,
'--dangerously-bypass-hook-trust',
@@ -670,14 +641,14 @@ function runProviderAgent(provider, prompt, {
prompt,
], {
cwd: targetRepo,
env,
logName,
logName: 'codex-agent-choice.log',
timeoutMs: 10 * 60 * 1000,
});
return;
}
if (provider === 'cursor') {
if (!cursorReady) ensureCursorAgent();
ensureCursorAgent();
const res = run('agent', [
'-p',
'--force',
@@ -687,8 +658,7 @@ function runProviderAgent(provider, prompt, {
prompt,
], {
cwd: targetRepo,
env,
logName,
logName: 'cursor-agent-choice.log',
timeoutMs: 10 * 60 * 1000,
allowFailure: true,
});
@@ -701,10 +671,10 @@ function runProviderAgent(provider, prompt, {
}
throw new Error(res.error ? `agent failed: ${res.error.message}` : `agent exited ${res.status}`);
}
return res;
return;
}
throw new Error(`Unsupported provider agent: ${provider}`);
throw new Error(`Unsupported agent-choice provider: ${provider}`);
}
function assertSpecificFontIgnoreConfig(provider, config) {
@@ -743,29 +713,84 @@ function readSharedHookConfig() {
}
function runInstalledProviderHook(provider, file, logName) {
const smoke = providerSmoke[provider];
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, logName) };
return run('node', [smoke.hook], {
cwd: targetRepo,
env,
logName: `direct-${provider}-confirmed-${logName.replace(/\.ndjson$/, '.log')}`,
input: JSON.stringify(smoke.event(file)),
});
if (provider === 'claude') {
return run('node', ['.claude/skills/impeccable/scripts/hook.mjs'], {
cwd: targetRepo,
env,
logName: `direct-${provider}-confirmed-${logName.replace(/\.ndjson$/, '.log')}`,
input: JSON.stringify(postToolUseEvent(`confirmed-${provider}`, file, 'Edit')),
});
}
if (provider === 'codex') {
return run('node', ['.agents/skills/impeccable/scripts/hook.mjs'], {
cwd: targetRepo,
env,
logName: `direct-${provider}-confirmed-${logName.replace(/\.ndjson$/, '.log')}`,
input: JSON.stringify(postToolUseEvent(`confirmed-${provider}`, file, 'apply_patch')),
});
}
if (provider === 'cursor') {
return run('node', ['.cursor/skills/impeccable/scripts/hook-before-edit.mjs'], {
cwd: targetRepo,
env,
logName: `direct-${provider}-confirmed-${logName.replace(/\.ndjson$/, '.log')}`,
input: JSON.stringify({
hook_event_name: 'preToolUse',
cwd: targetRepo,
tool_name: 'Write',
tool_input: {
file_path: file,
content: readFileSync(file, 'utf8'),
},
}),
});
}
throw new Error(`Unsupported confirmed exception provider: ${provider}`);
}
function confirmedSmokeFile(provider) {
if (provider === 'claude') return smokeFiles.confirmedClaude;
if (provider === 'codex') return smokeFiles.confirmedCodex;
if (provider === 'cursor') return smokeFiles.confirmedCursor;
throw new Error(`Unsupported confirmed exception provider: ${provider}`);
}
function agentChoiceSmokeFile(provider) {
if (provider === 'claude') return smokeFiles.agentChoiceClaude;
if (provider === 'codex') return smokeFiles.agentChoiceCodex;
if (provider === 'cursor') return smokeFiles.agentChoiceCursor;
throw new Error(`Unsupported agent-choice provider: ${provider}`);
}
function providerAdminScript(provider) {
if (provider === 'claude') return '.claude/skills/impeccable/scripts/hook-admin.mjs';
if (provider === 'codex') return '.agents/skills/impeccable/scripts/hook-admin.mjs';
if (provider === 'cursor') return '.cursor/skills/impeccable/scripts/hook-admin.mjs';
throw new Error(`Unsupported admin provider: ${provider}`);
}
function runClaudeProviderSmoke() {
clearRuntimeState();
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'claude.ndjson') };
const prompt = providerPrompt(providerSmoke.claude.fixture);
const res = runProviderAgent('claude', prompt, {
const prompt = providerPrompt(smokeFiles.claude);
const res = run('claude', [
'-p',
'--setting-sources', 'project',
'--permission-mode', 'acceptEdits',
'--tools', 'Read,Write,Edit',
'--allowedTools', 'Read Write Edit',
'--debug', 'hooks',
'--debug-file', join(smokeDir, 'claude-debug.log'),
prompt,
], {
cwd: targetRepo,
env,
logName: 'claude-provider.log',
claudeDebugLog: 'claude-debug.log',
claudeTools: 'Read,Write,Edit',
claudeAllowedTools: 'Read Write Edit',
timeoutMs: 10 * 60 * 1000,
});
const evidence = `${res.stdout}\n${res.stderr}\n${readMaybe(join(smokeDir, 'claude.ndjson'))}\n${readMaybe(join(smokeDir, 'claude-debug.log'))}`;
requireFile(providerSmoke.claude.fixture, 'Claude provider fixture');
requireFile(smokeFiles.claude, 'Claude provider fixture');
requireFinding('Claude provider hook', evidence);
if (!/PostToolUse|hook/i.test(evidence)) throw new Error('Claude provider evidence lacks hook/PostToolUse marker');
record('claude provider', true, 'Claude edit triggered PostToolUse hook and side-tab detection');
@@ -774,14 +799,23 @@ function runClaudeProviderSmoke() {
function runCodexProviderSmoke() {
clearRuntimeState();
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'codex.ndjson') };
const prompt = `Use apply_patch to ${providerPrompt(providerSmoke.codex.fixture)}`;
const res = runProviderAgent('codex', prompt, {
const prompt = `Use apply_patch to ${providerPrompt(smokeFiles.codex)}`;
const res = run('codex', [
'exec',
'-C', targetRepo,
'--dangerously-bypass-hook-trust',
'--dangerously-bypass-approvals-and-sandbox',
'--json',
prompt,
], {
cwd: targetRepo,
env,
logName: 'codex-provider.log',
timeoutMs: 10 * 60 * 1000,
});
const evidence = `${res.stdout}\n${res.stderr}\n${readMaybe(join(smokeDir, 'codex.ndjson'))}`;
const cacheEvidence = `${readMaybe(join(targetRepo, '.impeccable', 'hook.cache.json'))}\n${readMaybe(join(targetRepo, '.impeccable', 'hook.pending.json'))}`;
requireFile(providerSmoke.codex.fixture, 'Codex provider fixture');
requireFile(smokeFiles.codex, 'Codex provider fixture');
requireFinding('Codex provider hook', `${evidence}\n${cacheEvidence}`);
record('codex provider', true, 'Codex apply_patch triggered project hook and side-tab detection');
}
@@ -790,19 +824,37 @@ function runCursorProviderSmoke() {
ensureCursorAgent();
clearRuntimeState();
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'cursor.ndjson') };
const prompt = providerPrompt(providerSmoke.cursor.fixture);
const res = runProviderAgent('cursor', prompt, {
const prompt = providerPrompt(smokeFiles.cursor);
const res = run('agent', [
'-p',
'--force',
'--trust',
'--workspace', targetRepo,
'--output-format', 'stream-json',
prompt,
], {
cwd: targetRepo,
env,
logName: 'cursor-provider.log',
cursorReady: true,
timeoutMs: 10 * 60 * 1000,
allowFailure: true,
});
if (res.error || res.status !== 0) {
const output = `${res.stdout}\n${res.stderr}\n${res.error?.message || ''}`;
if (/Authentication required|agent login|CURSOR_API_KEY/i.test(output)) {
const err = new Error('Cursor CLI authentication required. Run `agent login` or set CURSOR_API_KEY, then rerun `bun run smoke:hooks -- --providers=cursor`.');
err.classification = 'cursor auth required';
throw err;
}
throw new Error(res.error ? `agent failed: ${res.error.message}` : `agent exited ${res.status}`);
}
const evidence = `${res.stdout}\n${res.stderr}\n${readMaybe(join(smokeDir, 'cursor.ndjson'))}\n${readMaybe(join(targetRepo, '.impeccable', 'hook.pending.json'))}\n${readMaybe(join(targetRepo, '.impeccable', 'hook.cache.json'))}`;
requireFinding('Cursor provider hook', evidence);
const auditEvents = readAuditEvents(join(smokeDir, 'cursor.ndjson'));
if (!auditEvents.some((event) => event.event === 'preToolUse' && event.blocked === true)) {
throw new Error('Cursor provider evidence lacks a preToolUse audit entry with blocked=true');
}
const fixturePath = join(targetRepo, providerSmoke.cursor.fixture);
const fixturePath = join(targetRepo, smokeFiles.cursor);
const intentionalIgnore = auditEvents.some((event) =>
event.event === 'preToolUse'
&& event.file === fixturePath
@@ -965,15 +1017,7 @@ function cleanSmokeArtifacts() {
}
function cleanSmokeFiles() {
const files = [
directSmokeFile,
...Object.values(providerSmoke).flatMap(({ fixture, confirmedFixture, agentChoiceFixture }) => [
fixture,
confirmedFixture,
agentChoiceFixture,
]),
];
for (const rel of files) {
for (const rel of Object.values(smokeFiles)) {
rmSync(join(targetRepo, rel), { force: true });
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `{{command_prefix}}impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
+2 -2
View File
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write',
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
+33
View File
@@ -196,6 +196,9 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -547,6 +550,36 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
+37
View File
@@ -0,0 +1,37 @@
/**
* Convert a live-config glob pattern to a RegExp.
*
* Supports `**` across path segments, `*` within one segment, and `?` for one
* character. Callers normalize project-relative paths to forward slashes.
*/
export function livePathGlobToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') {
re += '(?:.*/)?';
i += 3;
} else {
re += '.*';
i += 2;
}
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp(`^${re}$`);
}
+3 -11
View File
@@ -4902,13 +4902,6 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5803,7 +5796,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
return;
}
@@ -5891,7 +5884,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6336,7 +6329,7 @@
refreshParamsPanel();
positionBar();
saveSession();
completeParameterGenerationIfReady();
if (parameterGenerationState === 'loading') completeParameterPublication();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6843,7 +6836,6 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
+112 -68
View File
@@ -944,42 +944,8 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -999,27 +965,42 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
return failWithRollback({
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
});
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1032,44 +1013,72 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
return {
applied: [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
details: { unreportedFiles, notes: result.notes || [] },
});
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1080,10 +1089,21 @@ export async function commitManualEdits({
});
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1113,22 +1133,37 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
details: { notes: result.notes || [] },
});
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1145,7 +1180,16 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
...repairContext,
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
+2 -42
View File
@@ -27,6 +27,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import { livePathGlobToRegex } from './lib/live-path-globs.mjs';
import {
describeInjectArtifacts,
frameworkIgnorePatterns,
@@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) {
const patterns = config.files;
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
const excludeRegexes = allExcludes.map(globToRegex);
const excludeRegexes = allExcludes.map(livePathGlobToRegex);
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
const isGlob = (s) => /[*?[]/.test(s);
@@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) {
return out;
}
/**
* Convert a glob pattern to a RegExp. Supports:
* ** any number of path segments (including zero)
* * any chars except `/`
* ? any single char except `/`
* Paths are normalized to forward slashes before matching.
*/
function globToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
// ** — any number of segments, including zero. Handle the common
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
if (pattern[i + 2] === '/') {
re += '(?:.*/)?';
i += 3;
} else {
re += '.*';
i += 2;
}
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += '\\' + c;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp('^' + re + '$');
}
// ---------------------------------------------------------------------------
// Core operations
// ---------------------------------------------------------------------------
+2 -33
View File
@@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url';
import { resolveTargetSelection } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { livePathGlobToRegex } from './lib/live-path-globs.mjs';
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
import { resolveLiveTarget } from './live-target.mjs';
import { bootInstructions } from './live/instructions.mjs';
@@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) {
// Files matching the user's `exclude` globs are intentional omissions,
// not drift. Compile them to regexes so the orphan list stays signal.
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
.map((p) => globToRegex(p));
.map(livePathGlobToRegex);
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
const orphans = [];
@@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) {
};
}
/**
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
* to avoid a circular import (live-inject.mjs already imports nothing
* from live.mjs). The two must stay in sync.
*/
function globToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
else { re += '.*'; i += 2; }
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += '\\' + c;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp('^' + re + '$');
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
-48
View File
@@ -6,16 +6,9 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { boolFlag, parseArgs, positiveIntFlag, resolveEnum, toCamel } from '../scripts/lib/cli-args.mjs';
const PROVIDER_SMOKE_SCRIPT = fileURLToPath(new URL('../scripts/smoke-provider-hooks.mjs', import.meta.url));
describe('parseArgs', () => {
it('reads space-separated values', () => {
// The regression: without the argv[i+1] lookahead this yielded
@@ -132,44 +125,3 @@ describe('resolveEnum', () => {
);
});
});
describe('provider hook smoke CLI', () => {
it('prints help without requiring a target repository', () => {
const result = spawnSync(process.execPath, [PROVIDER_SMOKE_SCRIPT, '--help'], { encoding: 'utf8' });
assert.equal(result.status, 0);
assert.match(result.stdout, /^Usage: bun run smoke:hooks/);
assert.match(result.stdout, /target repo must be explicit/);
assert.equal(result.stderr, '');
});
it('fails with the same usage guidance when the target repository is omitted', () => {
const result = spawnSync(process.execPath, [PROVIDER_SMOKE_SCRIPT], { encoding: 'utf8' });
assert.equal(result.status, 1);
assert.equal(result.stdout, '');
assert.match(result.stderr, /^Usage: bun run smoke:hooks/);
assert.match(result.stderr, /target repo must be explicit/);
});
it('preserves the legacy string sentinel for value-less options', () => {
const cases = [
{ args: ['--repo'], error: /target repo does not exist: .*\/true/ },
{ args: ['--repo', '.', '--bundle'], error: /universal bundle does not exist: .*\/true/ },
{ args: ['--repo', '.', '--bundle', './missing.zip', '--providers'], error: /universal bundle does not exist: .*\/missing\.zip/ },
];
for (const { args, error } of cases) {
const cwd = mkdtempSync(join(tmpdir(), 'impeccable-provider-smoke-cli-'));
try {
const result = spawnSync(process.execPath, [PROVIDER_SMOKE_SCRIPT, ...args], { cwd, encoding: 'utf8' });
assert.equal(result.status, 1);
assert.doesNotMatch(result.stderr, /TypeError/);
assert.match(result.stderr, error);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}
});
});
@@ -221,19 +221,6 @@ describe('detectUrl — browser-only fixtures', () => {
assert.equal(contrast.length, 3, `expected exactly the 3 flag-column cases, got ${contrast.length}:\n${snippets}`);
});
it('ai-color-palette: oklch neon text flags the should-flag column only', async () => {
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/oklch-neon-text.html`, { visualContrast: false });
const neon = f.filter(r =>
r.antipattern === 'ai-color-palette' && /neon text on dark background/i.test(r.snippet || '')
);
assert.equal(
neon.length,
1,
`expected exactly 1 oklch neon-text finding, got ${neon.length}: ${JSON.stringify(f.map(r => r.snippet))}`,
);
assert.match(neon[0].snippet || '', /Cyan neon text on dark background/i);
});
it('shadowed form.id: a <form> with <input name="id"> does not crash the scan (issue #407)', async () => {
// HTMLFormElement named-property shadowing makes form.id / form.className
// return the child input element, whose .startsWith throws. Every Shopify
-77
View File
@@ -1,77 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OKLCH Neon Text Fixture</title>
<style>
:root {
--neon: oklch(0.85 0.2 195);
--muted: oklch(0.85 0.04 195);
--paper: oklch(0.9 0 0);
--ground: #050505;
--light: #f5f5f5;
}
body {
margin: 0;
padding: 32px;
background: var(--ground);
font-family: system-ui, sans-serif;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
max-width: 980px;
margin: 0 auto;
}
.column {
display: grid;
gap: 14px;
}
.column > h2 {
margin: 0 0 2px;
color: var(--paper);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.4;
text-transform: uppercase;
}
p {
margin: 0;
font-size: 18px;
}
.neon-cyan { color: var(--neon); }
.muted-cyan { color: var(--muted); }
.oklch-paper { color: var(--paper); }
.light-shell {
background: var(--light);
padding: 12px;
}
</style>
</head>
<body>
<main class="grid">
<section class="column" data-col="flag">
<h2>Should flag</h2>
<p class="neon-cyan">Cyan neon token</p>
</section>
<section class="column" data-col="pass">
<h2>Should pass</h2>
<p class="oklch-paper">Achromatic oklch on dark should pass</p>
<p class="muted-cyan">Muted cyan oklch on dark should pass</p>
<div class="light-shell">
<p class="neon-cyan">Cyan oklch on light ground should pass</p>
</div>
</section>
</main>
</body>
</html>
+1 -2
View File
@@ -9,7 +9,6 @@
--paper: #f7f3ee;
--ink: #171717;
--muted: #566174;
--flag-white: oklch(1 0 0);
}
body {
@@ -111,7 +110,7 @@
<h2>Should flag after pixel sampling</h2>
<article class="image-card light-image">
<p style="color: var(--flag-white);">White text on light image should be sampled by pixel contrast.</p>
<p style="color: rgb(255, 255, 255);">White text on light image should be sampled by pixel contrast.</p>
</article>
<article class="image-card dark-image">

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