mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
Addresses the review findings on #371, plus several the bots did not catch. All fixes have regression coverage that fails on the prior code. Source corruption: - Vue accept dropped valueless root attrs (disabled, v-cloak) and, worse, rewrote @click="x" as a literal click="x" DOM attribute, because the attr parser was name-anchored and skipped the sigil. Tokenize the whole Vue attr grammar and normalize shorthands so accept round-trips directives. - --variant was interpolated unescaped into a RegExp, so --variant '.*' matched the original block first and reported a successful accept while silently restoring the original. Validate against the digits pattern the browser and the /events schema already enforce. - --id reached path.join unvalidated, so --id ../../../../etc/evil wrote and read receipts outside the project. Hoist the existing safeSessionId check into impeccable-paths and apply it at every id-to-path sink. Accept/lock correctness: - Plain HTML/JSX accept and discard did not catch SOURCE_LOCKED, so contention exited non-zero with empty stdout and the agent got no JSON to retry on. - Lock staleness was mtime-only and never read the pid it records: a holder whose critical section outran 60s had its live lock swept, admitting a second writer to the same file, while a crashed holder blocked accepts for a full 60s. Decide staleness by owner liveness, and release only our own lock. Detector: - isNeutralColor only parses computed color forms, so routing authored CSS through it reported inset 4px 0 0 #000 / black / #e5e7eb as chromatic side-tab stripes. Add an authored-color neutrality test covering hex and named neutrals; the fixture had no literal-color cases at all. - Rule line numbers were off by one for every rule after the first, and commented-out CSS was scanned as live rules. Server: - An error reply carries no sourceEventType, and inferSourceEventType returned undefined, which acknowledgePendingEvent treats as a wildcard: a stale generate worker's failure consumed the user's queued Accept, which then reached no agent and left the browser in SAVING forever. - The generate preflight spawned live-wrap.mjs synchronously inside the request handler, freezing the single-threaded server for the whole scaffold (~7.6s measured on this repo, 15s ceiling) and stalling Accept/Discard/SSE. Make it async, claiming the lease before the first await so no event double-delivers. - Every browser checkpoint was echoed back as variant_progress, so a Tune slider drag remounted the preview under the user's cursor and latched the *_reviewable phases from the wrong trigger. Gate on the reason. Cleanup: - Collapse four divergent benchmark argv parsers into scripts/lib/cli-args.mjs. Three silently misread flags: --iterations 20 benchmarked 5, --agent llm ran the fake agent, --median-target=0.4 used the default threshold. - Drop a snapshot cache this branch made write-only (it grew per session for the server's lifetime and was never read), a dead exported reconcile helper, and the unused deferReply branch. Prepared with AI assistance under maintainer direction. Co-Authored-By: Claude <noreply@anthropic.com>
375 lines
15 KiB
JavaScript
375 lines
15 KiB
JavaScript
/**
|
|
* Nuxt/Vue live-mode component previews.
|
|
*
|
|
* Generation writes real Vue SFCs into a generated app-local module tree.
|
|
* Nuxt/Vite compiles those modules without touching the active route; Accept
|
|
* is the only operation that writes the user's .vue source.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { safeSessionId } from '../lib/impeccable-paths.mjs';
|
|
|
|
const NUXT_CONFIG_RE = /^nuxt\.config\.(?:js|mjs|cjs|ts|mts|cts)$/;
|
|
|
|
export function detectNuxtVueProject(cwd = process.cwd()) {
|
|
const configFile = fs.readdirSync(cwd, { withFileTypes: true })
|
|
.find((entry) => entry.isFile() && NUXT_CONFIG_RE.test(entry.name))?.name;
|
|
if (!configFile) return null;
|
|
const config = fs.readFileSync(path.join(cwd, configFile), 'utf-8');
|
|
const srcDirMatch = config.match(/\bsrcDir\s*:\s*(['"])([^'"]+)\1/);
|
|
let appDir = fs.existsSync(path.join(cwd, 'app')) ? 'app' : '';
|
|
if (srcDirMatch) {
|
|
const candidate = path.posix.normalize(srcDirMatch[2].replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, ''));
|
|
if (candidate !== '..' && !candidate.startsWith('../') && !path.isAbsolute(candidate)) {
|
|
appDir = candidate === '.' ? '' : candidate;
|
|
}
|
|
}
|
|
const componentRoot = [appDir, '.impeccable-live'].filter(Boolean).join('/');
|
|
return { configFile, appDir, componentRoot };
|
|
}
|
|
|
|
export function shouldUseVueComponentInjection(filePath, cwd = process.cwd()) {
|
|
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_VUE_COMPONENT || '')) return false;
|
|
return path.extname(filePath).toLowerCase() === '.vue' && !!detectNuxtVueProject(cwd);
|
|
}
|
|
|
|
export function vueComponentSessionDir(id, cwd = process.cwd()) {
|
|
const project = detectNuxtVueProject(cwd);
|
|
if (!project) throw new Error('Nuxt project not found');
|
|
return path.join(cwd, project.componentRoot, safeSessionId(id));
|
|
}
|
|
|
|
export function vueManifestPathForSession(id, cwd = process.cwd()) {
|
|
return path.join(vueComponentSessionDir(id, cwd), 'manifest.json');
|
|
}
|
|
|
|
function ensureVueRuntime(cwd = process.cwd()) {
|
|
const project = detectNuxtVueProject(cwd);
|
|
if (!project) throw new Error('Nuxt project not found');
|
|
const rel = `${project.componentRoot}/__runtime.js`;
|
|
const file = path.join(cwd, rel);
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const source = `import { createApp } from 'vue';\n\nexport function mount(Component, options = {}) {\n const app = createApp(Component, options.props || {});\n app.mount(options.target);\n return app;\n}\n\nexport async function unmount(app) {\n app?.unmount?.();\n}\n`;
|
|
if (!fs.existsSync(file) || fs.readFileSync(file, 'utf-8') !== source) fs.writeFileSync(file, source, 'utf-8');
|
|
return nuxtViteFsModulePath(file, cwd);
|
|
}
|
|
|
|
/**
|
|
* Nuxt mounts Vite beneath its build-assets base (normally `/_nuxt/`).
|
|
* Keep the manifest path base-agnostic and let the browser prepend the
|
|
* runtime's actual buildAssetsDir. A page-route URL such as
|
|
* `/app/.impeccable-live/x.vue` is handled by Nitro and returns HTML.
|
|
*/
|
|
export function nuxtViteFsModulePath(file, cwd = process.cwd()) {
|
|
const absolute = path.resolve(cwd, file).split(path.sep).join('/');
|
|
const relative = path.relative(cwd, absolute);
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
throw new Error('Nuxt live module must stay inside the project root');
|
|
}
|
|
return '/@fs/' + absolute.replace(/^\/+/, '');
|
|
}
|
|
|
|
export function extractVueExpressions(markup) {
|
|
const out = [];
|
|
const seen = new Set();
|
|
const re = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
|
let match;
|
|
while ((match = re.exec(String(markup || '')))) {
|
|
const expr = match[1].trim();
|
|
if (!expr || seen.has(expr)) continue;
|
|
seen.add(expr);
|
|
out.push({ expr, token: match[0] });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function buildVuePropContract(expressions) {
|
|
return expressions.map(({ expr, token }, index) => ({
|
|
prop: derivePropName(expr, index),
|
|
expr,
|
|
placeholder: token,
|
|
// DOMParser sees Vue interpolation `{{ user.name }}` as text containing
|
|
// the inner `{ user.name }` token; preserve its whitespace for the
|
|
// browser's source-text → rendered-text map.
|
|
previewToken: token.slice(1, -1),
|
|
}));
|
|
}
|
|
|
|
function derivePropName(expr, index) {
|
|
const tail = expr.match(/(?:^|\.|\[)([A-Za-z_$][\w$]*)\s*\]?$/);
|
|
return tail?.[1] || `prop${index}`;
|
|
}
|
|
|
|
function substituteVueExpressions(markup, contract) {
|
|
let out = String(markup || '');
|
|
for (const entry of contract) out = out.split(entry.placeholder).join(`{{ ${entry.prop} }}`);
|
|
return out;
|
|
}
|
|
|
|
function buildVueVariantStub(variant, markup, contract) {
|
|
const props = contract.length > 0
|
|
? `<script setup>\ndefineProps({\n${contract.map((entry) => ` ${entry.prop}: { default: '' },`).join('\n')}\n});\n</script>\n\n`
|
|
: '';
|
|
return `${props}<template>\n${markup.trim()}\n</template>\n\n<style scoped>\n/* Variant ${variant}: add scoped CSS here */\n</style>\n`;
|
|
}
|
|
|
|
export function scaffoldVueComponentSession({
|
|
id,
|
|
count,
|
|
sourceFile,
|
|
sourceStartLine,
|
|
sourceEndLine,
|
|
originalLines,
|
|
cwd = process.cwd(),
|
|
}) {
|
|
const runtimeModule = ensureVueRuntime(cwd);
|
|
const dir = vueComponentSessionDir(id, cwd);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const originalMarkup = originalLines.join('\n');
|
|
const propContract = buildVuePropContract(extractVueExpressions(originalMarkup));
|
|
const previewMarkup = substituteVueExpressions(originalMarkup, propContract);
|
|
const manifest = {
|
|
id,
|
|
previewMode: 'vue-component',
|
|
framework: 'vue',
|
|
componentExtension: 'vue',
|
|
sourceFile: sourceFile.split(path.sep).join('/'),
|
|
sourceStartLine,
|
|
sourceEndLine,
|
|
count,
|
|
propContract,
|
|
originalMarkup,
|
|
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
|
|
componentModuleBase: nuxtViteFsModulePath(dir, cwd),
|
|
runtimeModule,
|
|
};
|
|
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
|
for (let variant = 1; variant <= count; variant++) {
|
|
const file = path.join(dir, `v${variant}.vue`);
|
|
if (!fs.existsSync(file)) fs.writeFileSync(file, buildVueVariantStub(variant, previewMarkup, propContract), 'utf-8');
|
|
}
|
|
return {
|
|
manifest,
|
|
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
|
|
componentDir: manifest.componentDir,
|
|
propContract,
|
|
};
|
|
}
|
|
|
|
export function findVueComponentManifest(id, cwd = process.cwd()) {
|
|
let direct;
|
|
try { direct = vueManifestPathForSession(id, cwd); } catch { return null; }
|
|
if (!fs.existsSync(direct)) return null;
|
|
try {
|
|
const manifest = JSON.parse(fs.readFileSync(direct, 'utf-8'));
|
|
return manifest?.id === id && manifest?.previewMode === 'vue-component'
|
|
? { ...manifest, manifestPath: direct }
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseVueSfc(source) {
|
|
const text = String(source || '');
|
|
const template = text.match(/<template\b[^>]*>([\s\S]*?)<\/template\s*>/i)?.[1]?.trim() || '';
|
|
const style = text.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/i)?.[1]?.trim() || '';
|
|
return { template, cssLines: style ? style.split('\n').map((line) => line.trimEnd()) : [] };
|
|
}
|
|
|
|
function restoreVueExpressions(markup, contract) {
|
|
let out = String(markup || '');
|
|
for (const entry of contract || []) {
|
|
out = out.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(entry.prop)}\\s*\\}\\}`, 'g'), entry.placeholder);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function inlineVueComponentAccept(manifest, variantNum, cwd = process.cwd()) {
|
|
const sourcePath = resolveInside(cwd, manifest.sourceFile);
|
|
const componentDir = resolveInside(cwd, manifest.componentDir);
|
|
const variantPath = componentDir && path.join(componentDir, `v${variantNum}.vue`);
|
|
const resultBase = {
|
|
file: manifest.sourceFile,
|
|
sourceFile: manifest.sourceFile,
|
|
previewMode: 'vue-component',
|
|
componentDir: manifest.componentDir,
|
|
carbonize: false,
|
|
};
|
|
if (!sourcePath || !componentDir || !variantPath || !fs.existsSync(sourcePath) || !fs.existsSync(variantPath)) {
|
|
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
|
|
}
|
|
const { template, cssLines } = parseVueSfc(fs.readFileSync(variantPath, 'utf-8'));
|
|
if (!template) return { handled: false, error: 'Accepted Vue variant has no template', ...resultBase };
|
|
if (/\bdata-impeccable-[\w-]*\s*=/.test(template)) {
|
|
return { handled: false, error: 'Accepted Vue variant contains preview-only attributes', ...resultBase };
|
|
}
|
|
|
|
const sourceLines = fs.readFileSync(sourcePath, 'utf-8').split('\n');
|
|
const start = Number(manifest.sourceStartLine) - 1;
|
|
const end = Number(manifest.sourceEndLine) - 1;
|
|
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
|
|
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
|
|
}
|
|
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
|
|
const mergedTemplate = mergeOriginalVueAttrs(template, manifest.originalMarkup || '');
|
|
const markupLines = restoreVueExpressions(mergedTemplate, manifest.propContract)
|
|
.split('\n')
|
|
.map((line) => line.trim() ? indent + line.trimStart() : '');
|
|
let next = [...sourceLines.slice(0, start), ...markupLines, ...sourceLines.slice(end + 1)];
|
|
const meaningfulCss = cssLines.filter((line) => line.trim() && !/^\/\*\s*Variant \d+:/.test(line.trim()));
|
|
if (meaningfulCss.length > 0) next = appendVueStyle(next, meaningfulCss);
|
|
fs.writeFileSync(sourcePath, next.join('\n'), 'utf-8');
|
|
retireVueComponentSession(manifest.id, cwd);
|
|
return { handled: true, ...resultBase };
|
|
}
|
|
|
|
function appendVueStyle(lines, cssLines) {
|
|
let close = -1;
|
|
for (let index = lines.length - 1; index >= 0; index--) {
|
|
if (/<\/style\s*>/.test(lines[index])) { close = index; break; }
|
|
}
|
|
const block = ['', ...cssLines.map((line) => line.trim() ? ' ' + line.trimStart() : '')];
|
|
if (close < 0) return [...lines, '', '<style scoped>', ...block.slice(1), '</style>'];
|
|
return [...lines.slice(0, close), ...block, ...lines.slice(close)];
|
|
}
|
|
|
|
function mergeOriginalVueAttrs(markup, originalMarkup) {
|
|
const variant = matchOpeningTag(markup);
|
|
const original = matchOpeningTag(originalMarkup);
|
|
if (!variant || !original || variant.tag.toLowerCase() !== original.tag.toLowerCase()) return markup;
|
|
const variantAttrs = parseStaticAttrs(variant.attrs);
|
|
const originalAttrs = parseStaticAttrs(original.attrs);
|
|
const additions = [];
|
|
let attrs = variant.attrs;
|
|
|
|
const originalClass = originalAttrs.get('class');
|
|
const variantClass = variantAttrs.get('class');
|
|
if (originalClass && variantClass) {
|
|
const classes = [
|
|
...variantClass.value.split(/\s+/),
|
|
...originalClass.value.split(/\s+/),
|
|
].filter(Boolean);
|
|
const replacement = `class=${variantClass.quote}${[...new Set(classes)].join(' ')}${variantClass.quote}`;
|
|
attrs = attrs.slice(0, variantClass.start) + replacement + attrs.slice(variantClass.end);
|
|
} else if (originalClass) {
|
|
additions.push(originalClass.raw);
|
|
}
|
|
for (const [name, attr] of originalAttrs) {
|
|
if (name === 'class' || variantAttrs.has(name)) continue;
|
|
additions.push(attr.raw);
|
|
}
|
|
const open = `<${variant.tag}${attrs}${additions.map((attr) => ' ' + attr.trim()).join('')}${variant.close}`;
|
|
return markup.slice(0, variant.index) + open + markup.slice(variant.index + variant.raw.length);
|
|
}
|
|
|
|
function matchOpeningTag(markup) {
|
|
const match = String(markup || '').match(/<([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
|
|
return match ? {
|
|
raw: match[0],
|
|
tag: match[1],
|
|
attrs: match[2] || '',
|
|
close: match[3],
|
|
index: match.index || 0,
|
|
} : null;
|
|
}
|
|
|
|
/**
|
|
* Tokenize the attributes of a Vue opening tag.
|
|
*
|
|
* The name pattern is deliberately permissive so directive shorthands survive
|
|
* a round trip: `@click.prevent`, `:aria-label`, `:[dynamicKey]`, `#default`,
|
|
* and `v-cloak` are all one attribute each. A name-anchored pattern such as
|
|
* `[A-Za-z_:][\w:.-]*` skips the `@`/`#` sigil and re-matches from the bare
|
|
* name, which turns `@click="submit"` into a literal `click="submit"` DOM
|
|
* attribute on Accept. Values are optional so valueless attributes
|
|
* (`disabled`, `v-cloak`) are recorded rather than dropped.
|
|
*/
|
|
function parseStaticAttrs(attrs) {
|
|
const out = new Map();
|
|
const re = /([^\s"'=<>/]+)(?:\s*=\s*(?:(["'])([\s\S]*?)\2|([^\s"'=<>`]+)))?/g;
|
|
let match;
|
|
while ((match = re.exec(attrs))) {
|
|
const quoted = match[2] !== undefined;
|
|
const valueless = !quoted && match[4] === undefined;
|
|
out.set(normalizeVueAttrName(match[1]), {
|
|
raw: match[0],
|
|
value: valueless ? '' : (quoted ? match[3] : match[4]),
|
|
quote: quoted ? match[2] : '"',
|
|
valueless,
|
|
start: match.index,
|
|
end: match.index + match[0].length,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Collapse Vue's directive shorthands to their canonical form for identity
|
|
* comparison only (the raw text is what gets written back). Without this, an
|
|
* original `:aria-label` and a variant `v-bind:aria-label` read as two
|
|
* different attributes and Accept emits both, which is a Vue compile error.
|
|
*/
|
|
function normalizeVueAttrName(name) {
|
|
const raw = String(name);
|
|
if (raw.startsWith('@')) return `v-on:${raw.slice(1)}`;
|
|
if (raw.startsWith(':')) return `v-bind:${raw.slice(1)}`;
|
|
if (raw.startsWith('#')) return `v-slot:${raw.slice(1)}`;
|
|
if (raw.startsWith('.')) return `v-bind:${raw.slice(1)}.prop`;
|
|
return raw;
|
|
}
|
|
|
|
export function removeVueComponentSession(id, cwd = process.cwd()) {
|
|
try { fs.rmSync(vueComponentSessionDir(id, cwd), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
}
|
|
|
|
/**
|
|
* Make an accepted/discarded session undiscoverable immediately while keeping
|
|
* Vue modules that Vite has in its graph alive until Live shuts down. Deleting
|
|
* an imported SFC mid-session makes Nuxt's HMR client attempt to reload a
|
|
* missing module and emit a console error. The generated directory remains
|
|
* ignored and removeAllVueComponentSessions removes it on server shutdown.
|
|
*/
|
|
export function retireVueComponentSession(id, cwd = process.cwd()) {
|
|
let dir;
|
|
try { dir = vueComponentSessionDir(id, cwd); } catch { return; }
|
|
for (const name of ['manifest.json', 'params.json']) {
|
|
try { fs.rmSync(path.join(dir, name), { force: true }); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
export function removeAllVueComponentSessions(cwd = process.cwd()) {
|
|
const project = detectNuxtVueProject(cwd);
|
|
if (!project) return;
|
|
const root = path.join(cwd, project.componentRoot);
|
|
if (!fs.existsSync(root)) return;
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
|
|
export function buildVueComponentCssAuthoring(count) {
|
|
return {
|
|
mode: 'vue-component',
|
|
count,
|
|
requirements: [
|
|
'Write each variant as a real Vue SFC in componentDir/vN.vue.',
|
|
'Keep one root element inside <template> and put variant CSS in <style scoped>.',
|
|
'Keep propContract bindings as {{ propName }} instead of snapshot text.',
|
|
'Do not add data-impeccable-* attributes.',
|
|
],
|
|
forbidden: ['Rewriting sourceFile during preview', 'data-impeccable-* attributes', 'Off-brand replacement content'],
|
|
};
|
|
}
|
|
|
|
function resolveInside(cwd, value) {
|
|
if (!value || path.isAbsolute(value)) return null;
|
|
const full = path.resolve(cwd, value);
|
|
const rel = path.relative(cwd, full);
|
|
return !rel || rel.startsWith('..') || path.isAbsolute(rel) ? null : full;
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|