fix: compile-check svelte variants at publish time

Field failure (Codex session, 2026-07-28): the agent kept the seeded
stub style block and appended its own second top-level style element in
all three variants. Svelte forbids that, so the user saw a red Vite
compile overlay; the mount-ack loop then self-healed (failure event,
repair, republish, clean accept), but the overlay window is exactly the
kind of thing the user should never see.

The publish gate closes the class: a done reply for a component session
now compile-checks every variant with the app's own compiler BEFORE the
revision bump and the browser broadcast. Failures bounce as a 422 with
file, line, and message plus _instructions; live-poll surfaces the
details in the thrown reply error. The browser never imports a variant
that cannot compile.

Also: the stub guard comments warn that all CSS belongs in the single
existing style block, worded to never contain the literal "<style"
sequence (a mention inside a CSS comment truncates the string surgery
agents use to find the block; the fake test agent caught exactly that).
The JIT svelte instructions carry the same warning.

This work was produced with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-28 14:13:47 -07:00
co-authored by Claude Code
parent da68678e7e
commit dc5420b64f
5 changed files with 131 additions and 5 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ function generateInstructions(event, scriptsPath) {
function svelteComponentInstructions(event, scaffold, scriptsPath) {
const dir = scaffold.componentDir;
const count = event.count;
return `Svelte component preview. EDIT the existing stubs ${dir}/v1.svelte ... v${count}.svelte in place; never delete or recreate them; do not read them back (the prop-substituted markup is in scaffold.componentStubMarkup). Keep the stub's control flow ({#each}, {#if}) and propContract prop names exactly; never flatten a loop into literal items. The stub <style> is seeded with the source rules that style the selection; restyle or delete freely, and know that any seeded rule you do not re-declare is REMOVED from source on accept (the preview never applied it). Semantic class selectors only: no @scope, no data-impeccable-* attributes. Params go in ${dir}/params.json keyed by variant number (never an attribute); author knob CSS against var(--p-<id>, default) and :global([data-p-<id>="..."]). Reply with --file ${scaffold.file}. Accept later merges everything into ${scaffold.sourceFile} mechanically; you have no post-accept cleanup.`;
return `Svelte component preview. EDIT the existing stubs ${dir}/v1.svelte ... v${count}.svelte in place; never delete or recreate them; do not read them back (the prop-substituted markup is in scaffold.componentStubMarkup). Keep the stub's control flow ({#each}, {#if}) and propContract prop names exactly; never flatten a loop into literal items. The stub <style> is seeded with the source rules that style the selection; restyle or delete freely, and know that any seeded rule you do not re-declare is REMOVED from source on accept (the preview never applied it). ALL your CSS goes inside that ONE existing <style> block: Svelte forbids a second top-level style element, and a publish with a non-compiling variant is bounced back to you with file and line. Semantic class selectors only: no @scope, no data-impeccable-* attributes. Params go in ${dir}/params.json keyed by variant number (never an attribute); author knob CSS against var(--p-<id>, default) and :global([data-p-<id>="..."]). Reply with --file ${scaffold.file}. Accept later merges everything into ${scaffold.sourceFile} mechanically; you have no post-accept cleanup.`;
}
function deferredWrapperInstructions(event, scaffold, scriptsPath) {
+41 -2
View File
@@ -309,9 +309,13 @@ function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) {
const propsComment = contract.length > 0
? `\n<!-- Props: ${contract.map((c) => `${c.prop} (${c.kind}) <- {${c.expr}}`).join(', ')} -->\n`
: '';
// The guard comments must never contain the literal "<style" character
// sequence: agents (and the fake test agent) locate the style block with
// string searches, and a mention inside a comment truncates their surgery
// mid-comment.
const css = seededCss
? `\n<style>\n /* Variant ${variantNum}: seeded from the route's current rules; restyle freely */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n</style>\n`
: `\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
? `\n<style>\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n</style>\n`
: `\n<style>\n /* Variant ${variantNum}: add all CSS inside THIS block. Svelte allows exactly\n one top-level style element; a second one is a compile error. */\n</style>\n`;
return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`;
}
@@ -1042,6 +1046,41 @@ export function removeSvelteComponentSession(id, cwd = process.cwd()) {
} catch { /* non-fatal */ }
}
/**
* Compile-check every variant component of a session with the app's own
* compiler, BEFORE the browser ever imports them. A variant that does not
* compile (the classic: a second top-level <style> appended next to the
* seeded one) used to surface as a red Vite overlay in the user's page plus
* a mount-failure round trip; bounced at publish time it is a private
* agent-side fix with the exact file and line.
*/
export function compileCheckVariants(id, cwd = process.cwd()) {
const manifest = findSvelteComponentManifest(id, cwd);
if (!manifest || !manifest.manifestPath) return { ok: true, failures: [], checked: 0 };
const compiler = loadSvelteCompiler(cwd);
if (!compiler || typeof compiler.compile !== 'function') return { ok: true, failures: [], checked: 0 };
const sessionDir = path.dirname(manifest.manifestPath);
const failures = [];
let checked = 0;
let entries = [];
try { entries = fs.readdirSync(sessionDir); } catch { return { ok: true, failures: [], checked: 0 }; }
for (const name of entries) {
if (!/^v\d+\.svelte$/.test(name)) continue;
checked++;
try {
compiler.compile(fs.readFileSync(path.join(sessionDir, name), 'utf-8'), { generate: false });
} catch (err) {
failures.push({
file: `${manifest.componentDir}/${name}`,
line: err?.start?.line ?? null,
column: err?.start?.column ?? null,
message: String(err?.message || err).split('\n')[0].slice(0, 300),
});
}
}
return { ok: failures.length === 0, failures, checked };
}
/**
* Snapshot the agent-authored variant files into a fresh revision directory
* and stamp the manifest. Called by the server on every publish (`done`