Compare commits

..
Author SHA1 Message Date
Paul Bakaus 478325a2dd Fix stalled Tune state without params
Resolve pending Tune controls when the completed variant set contains no tunable parameters, while preserving deferred parameter publications.

AI assistance: implemented and validated by OpenAI Codex under maintainer authorization.
2026-08-20 09:18:33 -07:00
6 changed files with 95 additions and 72 deletions
-37
View File
@@ -1,37 +0,0 @@
/**
* Convert a live-config glob pattern to a RegExp.
*
* Supports `**` across path segments, `*` within one segment, and `?` for one
* character. Callers normalize project-relative paths to forward slashes.
*/
export function livePathGlobToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') {
re += '(?:.*/)?';
i += 3;
} else {
re += '.*';
i += 2;
}
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp(`^${re}$`);
}
+11 -3
View File
@@ -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');
+42 -2
View File
@@ -27,7 +27,6 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import { livePathGlobToRegex } from './lib/live-path-globs.mjs';
import {
describeInjectArtifacts,
frameworkIgnorePatterns,
@@ -365,7 +364,7 @@ export function resolveFiles(rootDir, config) {
const patterns = config.files;
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
const excludeRegexes = allExcludes.map(livePathGlobToRegex);
const excludeRegexes = allExcludes.map(globToRegex);
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
const isGlob = (s) => /[*?[]/.test(s);
@@ -402,6 +401,47 @@ export function resolveFiles(rootDir, config) {
return out;
}
/**
* Convert a glob pattern to a RegExp. Supports:
* ** → any number of path segments (including zero)
* * → any chars except `/`
* ? → any single char except `/`
* Paths are normalized to forward slashes before matching.
*/
function globToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
// ** — any number of segments, including zero. Handle the common
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
if (pattern[i + 2] === '/') {
re += '(?:.*/)?';
i += 3;
} else {
re += '.*';
i += 2;
}
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += '\\' + c;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp('^' + re + '$');
}
// ---------------------------------------------------------------------------
// Core operations
// ---------------------------------------------------------------------------
+33 -2
View File
@@ -24,7 +24,6 @@ import { fileURLToPath } from 'node:url';
import { resolveTargetSelection } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { livePathGlobToRegex } from './lib/live-path-globs.mjs';
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
import { resolveLiveTarget } from './live-target.mjs';
import { bootInstructions } from './live/instructions.mjs';
@@ -241,7 +240,7 @@ function scanForDrift(rootDir, resolvedFiles, config) {
// Files matching the user's `exclude` globs are intentional omissions,
// not drift. Compile them to regexes so the orphan list stays signal.
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
.map(livePathGlobToRegex);
.map((p) => globToRegex(p));
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
const orphans = [];
@@ -279,6 +278,38 @@ function scanForDrift(rootDir, resolvedFiles, config) {
};
}
/**
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
* to avoid a circular import (live-inject.mjs already imports nothing
* from live.mjs). The two must stay in sync.
*/
function globToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
else { re += '.*'; i += 2; }
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += '\\' + c;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp('^' + re + '$');
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
+9
View File
@@ -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');
});
-28
View File
@@ -10,38 +10,10 @@ import { dirname, join, relative, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { livePathGlobToRegex } from '../skill/scripts/lib/live-path-globs.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const INJECT = resolve(__dirname, '..', 'skill/scripts/live-inject.mjs');
describe('live path globs', () => {
it('matches recursive segments, including zero segments', () => {
const anywhere = livePathGlobToRegex('**/index.html');
assert.equal(anywhere.test('index.html'), true);
assert.equal(anywhere.test('public/index.html'), true);
assert.equal(anywhere.test('apps/web/public/index.html'), true);
const underPublic = livePathGlobToRegex('public/**/*.html');
assert.equal(underPublic.test('public/index.html'), true);
assert.equal(underPublic.test('public/docs/index.html'), true);
assert.equal(underPublic.test('src/index.html'), false);
});
it('keeps single-star and question-mark matches inside one segment', () => {
const pattern = livePathGlobToRegex('pages/*/item?.html');
assert.equal(pattern.test('pages/docs/item1.html'), true);
assert.equal(pattern.test('pages/docs/deep/item1.html'), false);
assert.equal(pattern.test('pages/docs/item12.html'), false);
});
it('treats regular-expression punctuation as literal path text', () => {
const pattern = livePathGlobToRegex('pages/[draft]/item+.html');
assert.equal(pattern.test('pages/[draft]/item+.html'), true);
assert.equal(pattern.test('pages/d/itemm.html'), false);
});
});
function runInject(cwd, configPath, args) {
try {
const out = execFileSync('node', [INJECT, ...args], {