Files
pbakaus_impeccable/skill/scripts/live/vue-component.mjs
T
Paul BakausandClaude 4e381305e1 Fix source-safety, detector, and lock defects in Live polling work
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>
2026-07-17 13:48:44 -07:00

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, '\\$&');
}