mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a7d0fbc50 | ||
|
|
ba873f7599 | ||
|
|
ddb609936a | ||
|
|
067665cc7e | ||
|
|
56f44523f7 | ||
|
|
5d4418e2dc | ||
|
|
abba4012ff | ||
|
|
e0a9d8e7d9 | ||
|
|
fccd91c6ac | ||
|
|
e5abceedc4 | ||
|
|
c29f30fa34 | ||
|
|
6360b27823 | ||
|
|
a66aefba80 | ||
|
|
77dd327080 | ||
|
|
ff1f15c7ad | ||
|
|
d2a9efb90f | ||
|
|
16a218e632 | ||
|
|
7b94585653 | ||
|
|
478325a2dd | ||
|
|
8d62b135fe | ||
|
|
611147a333 | ||
|
|
7d5c60d291 | ||
|
|
1f2c3f9d6b | ||
|
|
cf8f295dc3 | ||
|
|
665c51b903 |
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"description": "Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.",
|
||||
"description": "Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -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) {
|
||||
function isUpToDate(root, providers, bundleDir, scope, agentScope = scope) {
|
||||
const unique = deduplicateProviders(root, providers, scope);
|
||||
if (unique.length === 0) return false;
|
||||
|
||||
@@ -724,6 +724,8 @@ function isUpToDate(root, providers, bundleDir, scope) {
|
||||
if (bundleHash !== localHash) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerAgentsUpToDate(bundleDir, root, provider, agentScope)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -745,7 +747,8 @@ async function check() {
|
||||
console.log('Checking for updates...\n');
|
||||
try {
|
||||
const bundleDir = await downloadAndExtractBundle();
|
||||
const upToDate = isUpToDate(root, providers, bundleDir);
|
||||
const agentScope = isHomeDir(root) ? 'user' : undefined;
|
||||
const upToDate = isUpToDate(root, providers, bundleDir, undefined, agentScope);
|
||||
rmSync(bundleDir, { recursive: true, force: true });
|
||||
|
||||
if (upToDate) {
|
||||
@@ -1251,7 +1254,9 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
|
||||
}
|
||||
|
||||
// Native subagent definitions that ship in the bundle next to a provider's
|
||||
// skills. GitHub Copilot's live at `.github/agents/impeccable-*.agent.md`:
|
||||
// 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`:
|
||||
// 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
|
||||
@@ -1261,6 +1266,11 @@ 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'),
|
||||
@@ -1273,6 +1283,23 @@ 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 = [];
|
||||
@@ -1534,7 +1561,8 @@ function hookInstalledForProvider(root, provider) {
|
||||
|
||||
function valueHasImpeccableHookMarker(value) {
|
||||
if (typeof value === 'string') {
|
||||
return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => value.includes(marker));
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
return IMPECCABLE_HOOK_COMMAND_MARKERS.some(marker => normalized.includes(marker));
|
||||
}
|
||||
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
|
||||
if (value && typeof value === 'object') {
|
||||
@@ -2072,7 +2100,9 @@ function resolveUpdateTarget({ projectRoot, home, explicitScope }) {
|
||||
const homeRooted = isHomeDir(projectRoot);
|
||||
if (homeRooted && !explicitScope) {
|
||||
const providers = findInstalledProviders(home);
|
||||
return providers.length ? { root: home, scope: undefined, providers, scopeLabel: 'user level' } : null;
|
||||
return providers.length
|
||||
? { root: home, scope: undefined, agentScope: 'user', providers, scopeLabel: 'user level' }
|
||||
: null;
|
||||
}
|
||||
|
||||
const projectProviders = homeRooted ? [] : findImpeccableProviders(projectRoot, 'project');
|
||||
@@ -2203,7 +2233,7 @@ async function update(flags = []) {
|
||||
: { root: projectRoot, scope: 'project', providers: target.projectProviders, scopeLabel: 'this project' };
|
||||
}
|
||||
|
||||
const { root, scope } = target;
|
||||
const { root, scope, agentScope = scope } = target;
|
||||
console.log(`Updating the ${target.scopeLabel} install: ${formatPathForDisplay(root)} (${target.providers.join(', ')})`);
|
||||
const providers = target.providers;
|
||||
const linkedProviders = findLinkedProviders(root, providers, scope);
|
||||
@@ -2227,7 +2257,7 @@ async function update(flags = []) {
|
||||
}
|
||||
|
||||
// Compare local vs remote -- skip if already up to date
|
||||
if (isUpToDate(root, copyProviders, tmpDir, scope)) {
|
||||
if (isUpToDate(root, copyProviders, tmpDir, scope, agentScope)) {
|
||||
try {
|
||||
const wantHooks = installHooks && await decideHookInstall(root, copyProviders, { yes });
|
||||
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, copyProviders, { force }) : [];
|
||||
@@ -2263,7 +2293,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 }));
|
||||
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope: agentScope }));
|
||||
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
|
||||
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
}
|
||||
|
||||
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
|
||||
const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
|
||||
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
|
||||
|
||||
@@ -256,6 +257,153 @@ function stripCssComments(content) {
|
||||
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
function blankHtmlComments(text) {
|
||||
return text.replace(/<!--[\s\S]*?-->/g, comment => comment.replace(/[^\n]/g, ' '));
|
||||
}
|
||||
|
||||
function blankCssLineCommentsInStyleBlocks(text) {
|
||||
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
||||
let output = '';
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const inner = match[1];
|
||||
const openLength = match[0].length - inner.length - '</style>'.length;
|
||||
output += text.slice(lastIndex, match.index);
|
||||
output += match[0].slice(0, openLength);
|
||||
output += blankCssLineComments(inner);
|
||||
output += match[0].slice(openLength + inner.length);
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
return output + text.slice(lastIndex);
|
||||
}
|
||||
|
||||
function blankHtmlAndCssCommentsOutsideScripts(text) {
|
||||
const re = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
|
||||
let output = '';
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index))));
|
||||
output += match[0];
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex))));
|
||||
}
|
||||
|
||||
function blankCssLineComments(text) {
|
||||
let output = '';
|
||||
let state = 'code';
|
||||
let urlDepth = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
const next = text[i + 1];
|
||||
if (state === 'line') {
|
||||
if (char === '\n') {
|
||||
output += '\n';
|
||||
state = 'code';
|
||||
} else {
|
||||
output += ' ';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state === 'single' || state === 'double') {
|
||||
output += char;
|
||||
if (char === '\\' && next) {
|
||||
output += next;
|
||||
i++;
|
||||
} else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) {
|
||||
state = 'code';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const prev = output.length ? output[output.length - 1] : '';
|
||||
if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') {
|
||||
output += ' ';
|
||||
i++;
|
||||
state = 'line';
|
||||
continue;
|
||||
}
|
||||
if (char === "'") state = 'single';
|
||||
else if (char === '"') state = 'double';
|
||||
if (char === '(') {
|
||||
const behind = output.replace(/\s+$/, '');
|
||||
if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++;
|
||||
} else if (char === ')' && urlDepth) {
|
||||
urlDepth--;
|
||||
}
|
||||
output += char;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function findAstroFrontmatterClose(text) {
|
||||
if (!text.startsWith('---')) return -1;
|
||||
let cursor = text.indexOf('\n');
|
||||
if (cursor === -1) return -1;
|
||||
cursor += 1;
|
||||
while (cursor < text.length) {
|
||||
if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) {
|
||||
let end = cursor + 3;
|
||||
while (text[end] === ' ' || text[end] === '\t') end++;
|
||||
if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1;
|
||||
}
|
||||
const char = text[cursor];
|
||||
const next = text[cursor + 1];
|
||||
if (char === "'" || char === '"') {
|
||||
const close = findQuotedStringEnd(text, cursor, char);
|
||||
if (close === -1) return -1;
|
||||
cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '`') {
|
||||
const close = findTemplateLiteralEnd(text, cursor);
|
||||
if (close === -1) return -1;
|
||||
cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '/' && next === '/') {
|
||||
const lineEnd = text.indexOf('\n', cursor);
|
||||
if (lineEnd === -1) return -1;
|
||||
cursor = lineEnd;
|
||||
continue;
|
||||
}
|
||||
if (char === '/' && next === '*') {
|
||||
const commentEnd = text.indexOf('*/', cursor + 2);
|
||||
if (commentEnd === -1) return -1;
|
||||
cursor = commentEnd + 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '/' && next !== '/' && next !== '*') {
|
||||
const close = findRegexLiteralEnd(text, cursor);
|
||||
if (close !== -1) {
|
||||
cursor = close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
cursor++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function blankAstroFrontmatterComments(text) {
|
||||
const close = findAstroFrontmatterClose(text);
|
||||
if (close === -1) return text;
|
||||
return stripJsComments(text.slice(0, close)) + text.slice(close);
|
||||
}
|
||||
|
||||
function blankCommentsForMatchers(text, ext) {
|
||||
if (PAGE_ANALYZER_EXTS.has(ext)) {
|
||||
const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text;
|
||||
return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter);
|
||||
}
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
const withoutBlocks = stripCssComments(text);
|
||||
return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function firstOverusedGoogleFont(text) {
|
||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||
}
|
||||
@@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) {
|
||||
const ext = extFromFilePath(filePath);
|
||||
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
|
||||
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
|
||||
}) : content;
|
||||
}) : blankCommentsForMatchers(content, ext);
|
||||
const source = stripCssInJsComments(commentStrippedSource, ext);
|
||||
const lines = source.split('\n');
|
||||
|
||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||
// Enable block context for CSS files where related properties span multiple lines
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
}));
|
||||
@@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) {
|
||||
scanCssTextForPseudoStripe(text).map(hit =>
|
||||
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
|
||||
|
||||
if (cssLike.has(ext)) {
|
||||
if (STYLESHEET_EXTS.has(ext)) {
|
||||
findings.push(...scanInsetStripeCss(content, filePath));
|
||||
findings.push(...pseudoStripeFindings(content, 0));
|
||||
}
|
||||
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
|
||||
}, () => extractStyleBlocks(content, ext))
|
||||
: extractStyleBlocks(content, ext);
|
||||
for (const block of styleBlocks) {
|
||||
const blockLines = block.content.split('\n');
|
||||
const blockContent = blankCssLineComments(stripCssComments(block.content));
|
||||
const blockLines = blockContent.split('\n');
|
||||
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
|
||||
profile,
|
||||
phase: 'style-block',
|
||||
@@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) {
|
||||
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
|
||||
// reported every selector one line low. runRegexMatchers keeps startLine - 1
|
||||
// because it indexes its split lines from zero.
|
||||
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
|
||||
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
|
||||
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
|
||||
}
|
||||
|
||||
// Extract and scan CSS-in-JS template literals
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: buildClaudeCompatibleHooks(
|
||||
'Edit|Write|MultiEdit',
|
||||
'Edit|Write',
|
||||
CLAUDE_PROJECT_HOOK,
|
||||
SYSTEM_MESSAGE_NOTICE,
|
||||
),
|
||||
@@ -155,7 +155,7 @@ export function buildClaudeSettingsManifest() {
|
||||
export function buildClaudePluginHooksManifest() {
|
||||
return {
|
||||
hooks: buildClaudeCompatibleHooks(
|
||||
'Edit|Write|MultiEdit',
|
||||
'Edit|Write',
|
||||
CLAUDE_PLUGIN_HOOK,
|
||||
SYSTEM_MESSAGE_NOTICE,
|
||||
),
|
||||
|
||||
+107
-151
@@ -12,6 +12,8 @@ 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');
|
||||
@@ -32,31 +34,60 @@ if (args.help || args.h || !args.repo) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetRepo = resolve(args.repo);
|
||||
const bundlePath = resolve(args.bundle || defaultBundle);
|
||||
const selectedProviders = (args.providers || defaultProviders.join(','))
|
||||
const targetRepo = resolve(legacySmokeArg(args.repo));
|
||||
const bundlePath = resolve(legacySmokeArg(args.bundle) || defaultBundle);
|
||||
const selectedProviders = (legacySmokeArg(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 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 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 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)) {
|
||||
@@ -109,21 +140,6 @@ 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}`);
|
||||
}
|
||||
@@ -466,7 +482,7 @@ function assertNoPluginInstall() {
|
||||
|
||||
function runDirectContractChecks() {
|
||||
clearRuntimeState();
|
||||
const file = writeBadFixture(smokeFiles.direct);
|
||||
const file = writeBadFixture(directSmokeFile);
|
||||
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'direct.ndjson') };
|
||||
const claude = run('node', ['.claude/skills/impeccable/scripts/hook.mjs'], {
|
||||
cwd: targetRepo,
|
||||
@@ -495,7 +511,7 @@ function runDirectContractChecks() {
|
||||
cwd: targetRepo,
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: join(targetRepo, smokeFiles.direct),
|
||||
file_path: join(targetRepo, directSmokeFile),
|
||||
content: badFixtureContent(),
|
||||
},
|
||||
}),
|
||||
@@ -515,7 +531,7 @@ function runConfirmedExceptionPersistenceChecks() {
|
||||
|
||||
function runConfirmedExceptionForProvider(provider) {
|
||||
clearRuntimeState();
|
||||
const rel = confirmedSmokeFile(provider);
|
||||
const rel = providerSmoke[provider].confirmedFixture;
|
||||
const file = writeConfirmedFixture(rel);
|
||||
const beforeLog = `${provider}-confirmed-before.ndjson`;
|
||||
const afterLog = `${provider}-confirmed-after.ndjson`;
|
||||
@@ -528,7 +544,7 @@ function runConfirmedExceptionForProvider(provider) {
|
||||
assertNoSpecificFontIgnoreConfig(provider);
|
||||
|
||||
run('node', [
|
||||
providerAdminScript(provider),
|
||||
providerSmoke[provider].admin,
|
||||
'ignore-value',
|
||||
'overused-font',
|
||||
'Roboto',
|
||||
@@ -580,7 +596,7 @@ function runAgentChosenFontExceptionChecks() {
|
||||
|
||||
function runAgentChosenFontExceptionForProvider(provider) {
|
||||
clearRuntimeState();
|
||||
const rel = agentChoiceSmokeFile(provider);
|
||||
const rel = providerSmoke[provider].agentChoiceFixture;
|
||||
const file = writeConfirmedFixture(rel);
|
||||
const beforeLog = `${provider}-agent-choice-before.ndjson`;
|
||||
const afterLog = `${provider}-agent-choice-after.ndjson`;
|
||||
@@ -612,27 +628,40 @@ function runAgentChosenFontExceptionForProvider(provider) {
|
||||
}
|
||||
|
||||
function runProviderAgentFontException(provider, rel) {
|
||||
const prompt = fontExceptionPrompt(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,
|
||||
} = {}) {
|
||||
if (provider === 'claude') {
|
||||
run('claude', [
|
||||
return run('claude', [
|
||||
'-p',
|
||||
'--setting-sources', 'project',
|
||||
'--permission-mode', 'acceptEdits',
|
||||
'--tools', 'Read,Bash',
|
||||
'--allowedTools', 'Read Bash',
|
||||
'--tools', claudeTools,
|
||||
'--allowedTools', claudeAllowedTools,
|
||||
'--debug', 'hooks',
|
||||
'--debug-file', join(smokeDir, 'claude-agent-choice-debug.log'),
|
||||
'--debug-file', join(smokeDir, claudeDebugLog),
|
||||
prompt,
|
||||
], {
|
||||
cwd: targetRepo,
|
||||
logName: 'claude-agent-choice.log',
|
||||
env,
|
||||
logName,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'codex') {
|
||||
run('codex', [
|
||||
return run('codex', [
|
||||
'exec',
|
||||
'-C', targetRepo,
|
||||
'--dangerously-bypass-hook-trust',
|
||||
@@ -641,14 +670,14 @@ function runProviderAgentFontException(provider, rel) {
|
||||
prompt,
|
||||
], {
|
||||
cwd: targetRepo,
|
||||
logName: 'codex-agent-choice.log',
|
||||
env,
|
||||
logName,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'cursor') {
|
||||
ensureCursorAgent();
|
||||
if (!cursorReady) ensureCursorAgent();
|
||||
const res = run('agent', [
|
||||
'-p',
|
||||
'--force',
|
||||
@@ -658,7 +687,8 @@ function runProviderAgentFontException(provider, rel) {
|
||||
prompt,
|
||||
], {
|
||||
cwd: targetRepo,
|
||||
logName: 'cursor-agent-choice.log',
|
||||
env,
|
||||
logName,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
allowFailure: true,
|
||||
});
|
||||
@@ -671,10 +701,10 @@ function runProviderAgentFontException(provider, rel) {
|
||||
}
|
||||
throw new Error(res.error ? `agent failed: ${res.error.message}` : `agent exited ${res.status}`);
|
||||
}
|
||||
return;
|
||||
return res;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported agent-choice provider: ${provider}`);
|
||||
throw new Error(`Unsupported provider agent: ${provider}`);
|
||||
}
|
||||
|
||||
function assertSpecificFontIgnoreConfig(provider, config) {
|
||||
@@ -713,84 +743,29 @@ function readSharedHookConfig() {
|
||||
}
|
||||
|
||||
function runInstalledProviderHook(provider, file, logName) {
|
||||
const smoke = providerSmoke[provider];
|
||||
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, logName) };
|
||||
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}`);
|
||||
return run('node', [smoke.hook], {
|
||||
cwd: targetRepo,
|
||||
env,
|
||||
logName: `direct-${provider}-confirmed-${logName.replace(/\.ndjson$/, '.log')}`,
|
||||
input: JSON.stringify(smoke.event(file)),
|
||||
});
|
||||
}
|
||||
|
||||
function runClaudeProviderSmoke() {
|
||||
clearRuntimeState();
|
||||
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'claude.ndjson') };
|
||||
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,
|
||||
const prompt = providerPrompt(providerSmoke.claude.fixture);
|
||||
const res = runProviderAgent('claude', prompt, {
|
||||
env,
|
||||
logName: 'claude-provider.log',
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
claudeDebugLog: 'claude-debug.log',
|
||||
claudeTools: 'Read,Write,Edit',
|
||||
claudeAllowedTools: 'Read Write Edit',
|
||||
});
|
||||
const evidence = `${res.stdout}\n${res.stderr}\n${readMaybe(join(smokeDir, 'claude.ndjson'))}\n${readMaybe(join(smokeDir, 'claude-debug.log'))}`;
|
||||
requireFile(smokeFiles.claude, 'Claude provider fixture');
|
||||
requireFile(providerSmoke.claude.fixture, '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');
|
||||
@@ -799,23 +774,14 @@ function runClaudeProviderSmoke() {
|
||||
function runCodexProviderSmoke() {
|
||||
clearRuntimeState();
|
||||
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'codex.ndjson') };
|
||||
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,
|
||||
const prompt = `Use apply_patch to ${providerPrompt(providerSmoke.codex.fixture)}`;
|
||||
const res = runProviderAgent('codex', prompt, {
|
||||
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(smokeFiles.codex, 'Codex provider fixture');
|
||||
requireFile(providerSmoke.codex.fixture, 'Codex provider fixture');
|
||||
requireFinding('Codex provider hook', `${evidence}\n${cacheEvidence}`);
|
||||
record('codex provider', true, 'Codex apply_patch triggered project hook and side-tab detection');
|
||||
}
|
||||
@@ -824,37 +790,19 @@ function runCursorProviderSmoke() {
|
||||
ensureCursorAgent();
|
||||
clearRuntimeState();
|
||||
const env = { IMPECCABLE_HOOK_LOG: join(smokeDir, 'cursor.ndjson') };
|
||||
const prompt = providerPrompt(smokeFiles.cursor);
|
||||
const res = run('agent', [
|
||||
'-p',
|
||||
'--force',
|
||||
'--trust',
|
||||
'--workspace', targetRepo,
|
||||
'--output-format', 'stream-json',
|
||||
prompt,
|
||||
], {
|
||||
cwd: targetRepo,
|
||||
const prompt = providerPrompt(providerSmoke.cursor.fixture);
|
||||
const res = runProviderAgent('cursor', prompt, {
|
||||
env,
|
||||
logName: 'cursor-provider.log',
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
allowFailure: true,
|
||||
cursorReady: 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, smokeFiles.cursor);
|
||||
const fixturePath = join(targetRepo, providerSmoke.cursor.fixture);
|
||||
const intentionalIgnore = auditEvents.some((event) =>
|
||||
event.event === 'preToolUse'
|
||||
&& event.file === fixturePath
|
||||
@@ -1017,7 +965,15 @@ function cleanSmokeArtifacts() {
|
||||
}
|
||||
|
||||
function cleanSmokeFiles() {
|
||||
for (const rel of Object.values(smokeFiles)) {
|
||||
const files = [
|
||||
directSmokeFile,
|
||||
...Object.values(providerSmoke).flatMap(({ fixture, confirmedFixture, agentChoiceFixture }) => [
|
||||
fixture,
|
||||
confirmedFixture,
|
||||
agentChoiceFixture,
|
||||
]),
|
||||
];
|
||||
for (const rel of files) {
|
||||
rmSync(join(targetRepo, rel), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/MultiEdit on a UI file."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write 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/MultiEdit on UI files, full-rule deep pass on Stop.',
|
||||
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
matcher: 'Edit|Write',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
|
||||
@@ -196,9 +196,6 @@ 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 ----------
|
||||
|
||||
@@ -550,36 +547,6 @@ 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,6 +4902,13 @@
|
||||
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; }
|
||||
@@ -5796,7 +5803,7 @@
|
||||
setLiveState('CYCLING');
|
||||
showOrUpdateCyclingBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5884,7 +5891,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
|
||||
} catch (err) {
|
||||
console.error('[impeccable] Failed to mount component-preview variants:', err);
|
||||
@@ -6329,7 +6336,7 @@
|
||||
refreshParamsPanel();
|
||||
positionBar();
|
||||
saveSession();
|
||||
if (parameterGenerationState === 'loading') completeParameterPublication();
|
||||
completeParameterGenerationIfReady();
|
||||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -6836,6 +6843,7 @@
|
||||
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0) expectedVariants = expected;
|
||||
completeParameterGenerationIfReady();
|
||||
|
||||
if (arrivedVariants > 0) {
|
||||
setLiveState('CYCLING');
|
||||
|
||||
@@ -944,8 +944,42 @@ 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
|
||||
@@ -965,42 +999,27 @@ export async function commitManualEdits({
|
||||
chatAvailable,
|
||||
});
|
||||
} catch (err) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: failed.length > 0
|
||||
? failed
|
||||
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
|
||||
@@ -1013,72 +1032,44 @@ 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 {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
|
||||
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
if (unreportedFiles.length > 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: [...rollbackScope, ...unreportedFiles],
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
|
||||
files: result.files || [],
|
||||
unreportedFiles,
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { unreportedFiles, notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'done' && reportedAppliedIds.length === 0) {
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
notes: result.notes || [],
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
|
||||
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: aiFailed,
|
||||
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
verifiedIds: verifiedAppliedIds,
|
||||
failed: verificationFailed,
|
||||
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
|
||||
const unreportedEntries = result.status === 'done' || result.status === 'partial'
|
||||
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
|
||||
: [];
|
||||
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
|
||||
reason: 'rolled_back_due_to_failed_entry_source_changed',
|
||||
candidates: candidatesForEntry(batch, entry.id),
|
||||
}));
|
||||
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
|
||||
return {
|
||||
applied: [],
|
||||
return failWithRollback({
|
||||
scope: rollbackScope,
|
||||
extraFiles: result.files || [],
|
||||
failed: [
|
||||
...leakedUnapplied,
|
||||
...failed.filter((item) => !leakedIds.has(item.id)),
|
||||
...rolledBackVerified,
|
||||
],
|
||||
files: result.files || [],
|
||||
cleared: 0,
|
||||
count,
|
||||
pageUrl,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
notes: result.notes || [],
|
||||
...countByPage(cwd),
|
||||
};
|
||||
details: { notes: result.notes || [] },
|
||||
});
|
||||
}
|
||||
|
||||
if (verificationFailed.length > 0) {
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: reportedAppliedIds,
|
||||
files: result.files || [],
|
||||
failed: nonRepairFailed,
|
||||
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
|
||||
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
|
||||
: batch.entries;
|
||||
return repairPostApplyValidation({
|
||||
batch,
|
||||
cwd,
|
||||
pageUrl,
|
||||
count,
|
||||
provider,
|
||||
env,
|
||||
timeoutMs,
|
||||
applyBatchToSource,
|
||||
chatAvailable,
|
||||
transactionId,
|
||||
...repairContext,
|
||||
appliedEntryIds: verifiedAppliedIds.length > 0
|
||||
? verifiedAppliedIds
|
||||
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
|
||||
|
||||
@@ -6,9 +6,16 @@
|
||||
|
||||
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
|
||||
@@ -125,3 +132,44 @@ 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,6 +382,228 @@ describe('detectText — broken images in source comments', () => {
|
||||
|
||||
expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores img tags in Astro style block comments', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'const title = "Hero";',
|
||||
'---',
|
||||
'<style>',
|
||||
' /*',
|
||||
' * Example markup: <img src="">',
|
||||
' */',
|
||||
' .hero { color: red; }',
|
||||
'</style>',
|
||||
].join('\n');
|
||||
|
||||
const findings = detectText(source, 'hero.astro');
|
||||
|
||||
expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores img tags in Astro HTML comments', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'const title = "Hero";',
|
||||
'---',
|
||||
'<!-- <img src="" alt="Comment-only image" /> -->',
|
||||
'<img src="/logo.png" alt="Logo" />',
|
||||
].join('\n');
|
||||
|
||||
const findings = detectText(source, 'hero.astro');
|
||||
|
||||
expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores img tags in Astro frontmatter line comments', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'// <img src="" alt="Comment-only image" />',
|
||||
'const site = "https://example.com";',
|
||||
'---',
|
||||
'<img src="/logo.png" alt="Logo" />',
|
||||
].join('\n');
|
||||
|
||||
const findings = detectText(source, 'hero.astro');
|
||||
|
||||
expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores img tags in CSS block comments', () => {
|
||||
const source = [
|
||||
'/*',
|
||||
' * Example markup: <img src="">',
|
||||
' */',
|
||||
'.hero { color: red; }',
|
||||
].join('\n');
|
||||
|
||||
const findings = detectText(source, 'hero.css');
|
||||
|
||||
expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('still detects real img tags after an HTML comment in Astro', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'const title = "Hero";',
|
||||
'---',
|
||||
'<!-- decorative only -->',
|
||||
'<img src="" alt="Empty source" />',
|
||||
].join('\n');
|
||||
|
||||
const findings = detectText(source, 'hero.astro');
|
||||
|
||||
const broken = findings.filter(r => r.antipattern === 'broken-image');
|
||||
expect(broken).toHaveLength(1);
|
||||
expect(broken[0].line).toBe(5);
|
||||
});
|
||||
|
||||
test('does not blank https URLs in Astro frontmatter', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'const site = "https://example.com/logo.png";',
|
||||
'---',
|
||||
'<img src="" alt="Empty source" />',
|
||||
].join('\n');
|
||||
|
||||
const findings = detectText(source, 'hero.astro');
|
||||
|
||||
expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps same-line img visible after a bare https URL in Astro markup', () => {
|
||||
const source = '<p>https://example.com <img src="" alt="Empty source" /></p>';
|
||||
|
||||
const findings = detectText(source, 'hero.astro');
|
||||
|
||||
expect(findings.filter(r => r.antipattern === 'broken-image')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('preserves line numbers after comment blanking in Astro', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'const title = "Hero";',
|
||||
'---',
|
||||
'<!-- <img src="" alt="Comment-only image" /> -->',
|
||||
'<p>Intro copy</p>',
|
||||
'<img src="" alt="Empty source" />',
|
||||
].join('\n');
|
||||
|
||||
const findings = detectText(source, 'hero.astro');
|
||||
|
||||
const broken = findings.filter(r => r.antipattern === 'broken-image');
|
||||
expect(broken).toHaveLength(1);
|
||||
expect(broken[0].line).toBe(6);
|
||||
});
|
||||
|
||||
test('does not treat comment markers inside script strings as markup comments', () => {
|
||||
const htmlDelimiters = [
|
||||
'<script>const open = "<!--";</script>',
|
||||
'<img>',
|
||||
'<script>const close = "-->";</script>',
|
||||
].join('\n');
|
||||
const cssDelimiters = [
|
||||
'<script>const open = "/*";</script>',
|
||||
'<img>',
|
||||
'<script>const close = "*/";</script>',
|
||||
].join('\n');
|
||||
|
||||
for (const filePath of ['hero.astro', 'hero.vue', 'hero.svelte']) {
|
||||
expect(detectText(htmlDelimiters, filePath).filter(r => r.antipattern === 'broken-image')).toHaveLength(1);
|
||||
expect(detectText(cssDelimiters, filePath).filter(r => r.antipattern === 'broken-image')).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores preprocessor line comments in stylesheets', () => {
|
||||
const source = '// font-family: Inter\n.hero { color: red; }';
|
||||
|
||||
for (const filePath of ['hero.scss', 'hero.sass', 'hero.less']) {
|
||||
expect(detectText(source, filePath).filter(r => r.antipattern === 'overused-font')).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('still detects live font-family after a preprocessor line comment', () => {
|
||||
const source = '// skip this\n.hero { font-family: Inter; }';
|
||||
|
||||
const findings = detectText(source, 'hero.scss').filter(r => r.antipattern === 'overused-font');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].line).toBe(2);
|
||||
});
|
||||
|
||||
test('does not blank https URLs in SCSS', () => {
|
||||
const source = '.hero { background: url(https://example.com/i.png); }\n.hero { font-family: Inter; }';
|
||||
|
||||
const findings = detectText(source, 'hero.scss').filter(r => r.antipattern === 'overused-font');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].line).toBe(2);
|
||||
});
|
||||
|
||||
test('ignores frontmatter comments after a --- line inside a template literal', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'const md = `',
|
||||
'---',
|
||||
'`;',
|
||||
'// <img src="" alt="Comment-only image" />',
|
||||
'---',
|
||||
'<div>ok</div>',
|
||||
].join('\n');
|
||||
|
||||
expect(detectText(source, 'hero.astro').filter(r => r.antipattern === 'broken-image')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('ignores preprocessor line comments in component style blocks', () => {
|
||||
const source = [
|
||||
'<style lang="scss">',
|
||||
'// font-family: Inter',
|
||||
'.hero { color: red; }',
|
||||
'</style>',
|
||||
].join('\n');
|
||||
|
||||
for (const filePath of ['hero.astro', 'hero.vue', 'hero.svelte']) {
|
||||
expect(detectText(source, filePath).filter(r => r.antipattern === 'overused-font')).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('still detects live font-family after a style-block line comment', () => {
|
||||
const source = [
|
||||
'<style lang="scss">',
|
||||
'// skip this',
|
||||
'.hero { font-family: Inter; }',
|
||||
'</style>',
|
||||
].join('\n');
|
||||
|
||||
const findings = detectText(source, 'hero.vue').filter(r => r.antipattern === 'overused-font');
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0].line).toBe(3);
|
||||
});
|
||||
|
||||
test('ignores frontmatter comments after a regex literal that contains quotes', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'const re = /["\']/;',
|
||||
'// <img src="" alt="Comment-only image" />',
|
||||
'---',
|
||||
'<div>ok</div>',
|
||||
].join('\n');
|
||||
|
||||
expect(detectText(source, 'hero.astro').filter(r => r.antipattern === 'broken-image')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('keeps live font-family after a protocol-relative URL in SCSS', () => {
|
||||
const sources = [
|
||||
'.hero { background: url( //cdn.example.com/i.png); font-family: Inter; }',
|
||||
'.hero { background: url(#{$prefix}//cdn.example.com/i.png); font-family: Inter; }',
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
expect(detectText(source, 'hero.scss').filter(r => r.antipattern === 'overused-font')).toHaveLength(1);
|
||||
}
|
||||
expect(detectText(
|
||||
'.hero { background: url(@{prefix}//cdn.example.com/i.png); font-family: Inter; }',
|
||||
'hero.less',
|
||||
).filter(r => r.antipattern === 'overused-font')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectText — CSS borders', () => {
|
||||
|
||||
@@ -72,7 +72,8 @@ describe('hook manifest builders', () => {
|
||||
const group = manifest.hooks.PostToolUse[0];
|
||||
const handler = group.hooks[0];
|
||||
|
||||
assert.equal(group.matcher, 'Edit|Write|MultiEdit');
|
||||
assert.equal(group.matcher, 'Edit|Write');
|
||||
assert.doesNotMatch(manifest.description, /MultiEdit/);
|
||||
assert.equal(handler.type, 'command');
|
||||
assert.equal(handler.timeout, 5);
|
||||
assert.equal(handler.statusMessage, 'Checking UI changes');
|
||||
@@ -356,7 +357,7 @@ describe('generated hook artifacts in repo', () => {
|
||||
assert.equal(manifest.description, undefined);
|
||||
|
||||
const handler = manifest.hooks.PostToolUse[0].hooks[0];
|
||||
assert.equal(manifest.hooks.PostToolUse[0].matcher, 'Edit|Write|MultiEdit');
|
||||
assert.equal(manifest.hooks.PostToolUse[0].matcher, 'Edit|Write');
|
||||
expectCommand(handler.command, 'skills/impeccable/scripts/hook.mjs');
|
||||
// Resolves relative to the installed plugin, not a `.claude/skills/` layout.
|
||||
assert.ok(handler.command.includes('${CLAUDE_PLUGIN_ROOT}'),
|
||||
@@ -375,6 +376,15 @@ describe('generated hook artifacts in repo', () => {
|
||||
assert.ok(fs.existsSync(path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook-lib.mjs')));
|
||||
});
|
||||
|
||||
it('keeps the marketplace hook repair matcher aligned with Claude Code', () => {
|
||||
const hookAdmin = fs.readFileSync(
|
||||
path.join(REPO_ROOT, 'plugin/skills/impeccable/scripts/hook-admin.mjs'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(hookAdmin, /matcher: 'Edit\|Write'/);
|
||||
assert.doesNotMatch(hookAdmin, /matcher: 'Edit\|Write\|MultiEdit'/);
|
||||
});
|
||||
|
||||
it('generated hook runtime can import the bundled detector', async () => {
|
||||
for (const scriptDir of [
|
||||
'.claude/skills/impeccable/scripts',
|
||||
|
||||
@@ -947,6 +947,11 @@ describe('hook-admin.mjs', () => {
|
||||
// impeccable entry must have been stripped, not accumulated.
|
||||
assert.equal(claude.split('skills/impeccable/scripts/hook.mjs').length - 1, 2);
|
||||
assert.match(claude, /"Stop"/);
|
||||
const claudeManifest = JSON.parse(claude);
|
||||
const impeccableGroup = claudeManifest.hooks.PostToolUse.find((group) =>
|
||||
group.hooks?.some((hook) => hook.command?.includes('skills/impeccable/scripts/hook.mjs')));
|
||||
assert.ok(impeccableGroup, 'repaired Claude settings should contain the Impeccable PostToolUse group');
|
||||
assert.equal(impeccableGroup.matcher, 'Edit|Write');
|
||||
|
||||
const codex = fs.readFileSync(path.join(cwd, '.codex', 'hooks.json'), 'utf-8');
|
||||
assert.match(codex, /\.agents\/skills\/impeccable\/scripts\/hook\.mjs/);
|
||||
|
||||
@@ -1032,6 +1032,15 @@ describe('live-browser.js regression guards', () => {
|
||||
/case 'variant_progress':[\s\S]{0,120}?if \(msg\.publicationKind === 'params'\) parameterGenerationState = 'loading';/,
|
||||
'a params-only publication must mark Tune controls loading even though the variant count is unchanged',
|
||||
);
|
||||
assert.match(
|
||||
SOURCE,
|
||||
/function completeParameterGenerationIfReady\(\) \{[\s\S]{0,240}?arrivedVariants < expectedVariants[\s\S]{0,160}?parameterGenerationState === 'pending'[\s\S]{0,120}?completeParameterPublication\(\);/,
|
||||
'the completed variants publication must resolve pending Tune controls even when no params publication follows',
|
||||
);
|
||||
assert.ok(
|
||||
(SOURCE.match(/completeParameterGenerationIfReady\(\);/g) || []).length >= 4,
|
||||
'every DOM, source, and component-preview completion path must resolve pending Tune controls',
|
||||
);
|
||||
assert.match(SOURCE, /revisionDomain: 'browser'/, 'browser checkpoints must use their own revision domain');
|
||||
});
|
||||
|
||||
|
||||
+117
-2
@@ -84,11 +84,13 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu
|
||||
writeFileSync(join(skillDir, 'scripts', 'context.mjs'), 'console.log("local bundle context");\n');
|
||||
}
|
||||
if (providers.includes('.claude')) {
|
||||
mkdirSync(join(bundleRoot, '.claude'), { recursive: true });
|
||||
mkdirSync(join(bundleRoot, '.claude', 'agents'), { recursive: true });
|
||||
writeFileSync(join(bundleRoot, '.claude', 'settings.json'), JSON.stringify({
|
||||
description: 'fresh claude hook',
|
||||
hooks: { PostToolUse: [{ matcher: 'Edit', hooks: [{ type: 'command', command: 'node ".claude/skills/impeccable/scripts/hook.mjs"' }] }] },
|
||||
}, null, 2));
|
||||
writeFileSync(join(bundleRoot, '.claude', 'agents', 'impeccable-finish-reviewer.md'),
|
||||
'---\nname: impeccable-finish-reviewer\ndescription: Reviews a finished build.\n---\nClaude reviewer body.\n');
|
||||
}
|
||||
if (providers.includes('.cursor')) {
|
||||
mkdirSync(join(bundleRoot, '.cursor'), { recursive: true });
|
||||
@@ -228,7 +230,51 @@ describe('copyProviderSkills: symlink handling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyProviderAgents: Copilot and Cursor subagents', () => {
|
||||
describe('copyProviderAgents: Claude, Copilot, and Cursor subagents', () => {
|
||||
test('Claude project and user scopes use .claude/agents, with project copies taking precedence', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-claude-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-agents-claude-home-'));
|
||||
const bundle = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
mkdirSync(join(home, '.claude', 'agents'), { recursive: true });
|
||||
writeFileSync(join(home, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'stale copy\n');
|
||||
|
||||
const projectResults = copyProviderAgents(bundle, tmp, ['.claude'], { scope: 'project', home });
|
||||
const userResults = copyProviderAgents(bundle, home, ['.claude'], { scope: 'user' });
|
||||
|
||||
expect(projectResults).toHaveLength(1);
|
||||
expect(projectResults[0].shadowed).toEqual([]);
|
||||
expect(userResults).toHaveLength(1);
|
||||
expect(readFileSync(join(tmp, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'utf8'))
|
||||
.toContain('Claude reviewer body.');
|
||||
expect(readFileSync(join(home, '.claude', 'agents', 'impeccable-finish-reviewer.md'), 'utf8'))
|
||||
.toContain('Claude reviewer body.');
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('Claude install and update backfill bundled agents beside an unchanged skill', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-claude-install-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-agents-claude-install-home-'));
|
||||
execSync('git init', { cwd: tmp });
|
||||
const bundleRoot = createFakeUniversalBundle(tmp, ['.claude']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
const agentPath = join(tmp, '.claude', 'agents', 'impeccable-finish-reviewer.md');
|
||||
|
||||
const installOutput = run('skills install -y --no-hooks --providers=claude', { cwd: tmp, env });
|
||||
expect(installOutput).toContain('Installed Claude Code agents into:');
|
||||
expect(existsSync(agentPath)).toBe(true);
|
||||
|
||||
rmSync(agentPath);
|
||||
const updateOutput = run('skills update -y --no-hooks', { cwd: tmp, env });
|
||||
expect(updateOutput).toContain('Updated');
|
||||
expect(updateOutput).toContain('Installed Claude Code agents into:');
|
||||
expect(existsSync(agentPath)).toBe(true);
|
||||
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('project scope places agents at .github/agents/ and .cursor/agents/', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-project-'));
|
||||
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
|
||||
@@ -262,6 +308,46 @@ describe('copyProviderAgents: Copilot and Cursor subagents', () => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('skills check accepts current Copilot user agents in a home-rooted checkout', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-agents-check-home-'));
|
||||
execSync('git init', { cwd: home });
|
||||
const bundleRoot = createFakeUniversalBundle(home, ['.github']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
|
||||
run('skills install -y --scope=global --no-hooks --providers=github', { cwd: home, env });
|
||||
expect(existsSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
|
||||
expect(existsSync(join(home, '.github', 'agents'))).toBe(false);
|
||||
|
||||
const output = run('skills check', { cwd: home, env });
|
||||
expect(output).toContain('Skills are up to date');
|
||||
expect(output).not.toContain('Updates available');
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 15000);
|
||||
|
||||
test('inferred home-rooted updates refresh stale or missing Copilot user agents', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-agents-update-home-'));
|
||||
execSync('git init', { cwd: home });
|
||||
const bundleRoot = createFakeUniversalBundle(home, ['.github']);
|
||||
const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot };
|
||||
const userAgent = join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md');
|
||||
const projectAgent = join(home, '.github', 'agents', 'impeccable-finish-reviewer.agent.md');
|
||||
|
||||
run('skills install -y --scope=global --no-hooks --providers=github', { cwd: home, env });
|
||||
writeFileSync(userAgent, 'stale copy\n');
|
||||
|
||||
run('skills update -y --no-hooks', { cwd: home, env });
|
||||
expect(readFileSync(userAgent, 'utf8')).toContain('Copilot reviewer body.');
|
||||
expect(existsSync(projectAgent)).toBe(false);
|
||||
|
||||
rmSync(userAgent);
|
||||
run('skills update -y --no-hooks', { cwd: home, env });
|
||||
expect(readFileSync(userAgent, 'utf8')).toContain('Copilot reviewer body.');
|
||||
expect(existsSync(projectAgent)).toBe(false);
|
||||
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}, 20000);
|
||||
|
||||
test('project scope reports user-level Copilot agents that shadow the installed ones; Cursor never does (project wins there)', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-'));
|
||||
const home = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-home-'));
|
||||
@@ -1649,6 +1735,35 @@ describe('hook manifest merge helpers', () => {
|
||||
'node .cursor/skills/impeccable/scripts/hook-before-edit.mjs',
|
||||
]);
|
||||
});
|
||||
|
||||
test('mergeHookManifests replaces legacy Windows-path Claude hooks (#604)', () => {
|
||||
const legacyPath = 'C:\\Users\\alice\\.claude\\skills\\impeccable\\scripts\\hook.mjs';
|
||||
const legacyCommand = `[ ! -f "${legacyPath}" ] || node "${legacyPath}"`;
|
||||
const freshCommand = `node -e "guard" "${legacyPath}"`;
|
||||
const merged = mergeHookManifests(
|
||||
{
|
||||
hooks: {
|
||||
PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
|
||||
{ type: 'command', command: legacyCommand },
|
||||
] }],
|
||||
Stop: [{ hooks: [{ type: 'command', command: legacyCommand }] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
hooks: {
|
||||
PostToolUse: [{ matcher: 'Edit|Write|MultiEdit', hooks: [
|
||||
{ type: 'command', command: freshCommand },
|
||||
] }],
|
||||
Stop: [{ hooks: [{ type: 'command', command: freshCommand }] }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(merged.hooks.Stop).toHaveLength(1);
|
||||
expect(merged.hooks.PostToolUse[0].hooks[0].command).toBe(freshCommand);
|
||||
expect(merged.hooks.Stop[0].hooks[0].command).toBe(freshCommand);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Hook command path resolution (issue #399, part 1) ───────────────────────
|
||||
|
||||
Reference in New Issue
Block a user