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
+5 -2
View File
@@ -119,8 +119,11 @@ export async function postReply(base, token, reply) {
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const parts = [body.error || res.statusText, body.reason, body.hint].filter(Boolean);
throw new Error(parts.join(': '));
const failureLines = Array.isArray(body.failures)
? body.failures.map((f) => ` ${f.file}${f.line != null ? `:${f.line}` : ''} ${f.message}`).join('\n')
: null;
const parts = [body.error || res.statusText, body.reason, body.hint, failureLines, body._instructions].filter(Boolean);
throw new Error(parts.join('\n'));
}
}
+16
View File
@@ -55,6 +55,7 @@ import {
import {
applyDeferredSvelteComponentAccepts,
bumpSvelteComponentPreviewRevision,
compileCheckVariants,
removeAllSvelteComponentSessions,
sweepInactiveSvelteComponentSessions,
} from './live/svelte-component.mjs';
@@ -1322,9 +1323,24 @@ function handlePollPost(req, res) {
// variant files into a fresh revision dir before the browser is told:
// the import path changes every publish, so no transform cache can pin a
// stale compile of a republished module (node_modules is unwatched).
// Broken variants are bounced HERE, before the browser imports anything:
// a compile error that reaches the page is a red overlay in the user's
// face; bounced at publish it is a private fix with file and line.
if (replyFileMeta.previewMode === 'svelte-component'
&& msg.id
&& (msg.type === 'done' || !msg.type)) {
let compileCheck = { ok: true, failures: [] };
try { compileCheck = compileCheckVariants(msg.id, process.cwd()); } catch { /* best-effort */ }
if (!compileCheck.ok) {
res.writeHead(422, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: 'variant_compile_failed',
id: msg.id,
failures: compileCheck.failures,
_instructions: 'The publish was NOT delivered: the listed variant file(s) do not compile, so the browser never saw them. Fix each failure at the given file and line (the most common cause is a second top-level <style> element; Svelte allows exactly one, so merge all rules into the existing block), then send the same --reply done again.',
}));
return;
}
try { bumpSvelteComponentPreviewRevision(msg.id, process.cwd()); } catch { /* best-effort */ }
}
if (state.sessionStore && msg.id && !skipJournalReply) {
+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`
@@ -5,6 +5,7 @@ import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import {
compileCheckVariants,
extractMatchingSourceCss,
removeSelectorsFromSvelteSource,
findSvelteComponentManifest,
@@ -353,3 +354,70 @@ describe('review regressions: preview-truth supersession (the Pitch mangle)', ()
assert.deepEqual(removed, ['.b']);
});
});
describe('review regressions: publish-time compile gate', () => {
it('flags a variant with a duplicate top-level style block, passes after the fix', () => {
const tmp3 = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-compile-gate-')));
try {
mkdirSync(join(tmp3, 'node_modules'), { recursive: true });
try {
symlinkSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp3, 'node_modules', 'svelte'), 'dir');
} catch {
cpSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp3, 'node_modules', 'svelte'), { recursive: true });
}
write(tmp3, 'package.json', JSON.stringify({ name: 'app' }));
write(tmp3, 'src/routes/+page.svelte', '<main>\n <div class="pick">hi</div>\n</main>\n');
const session = scaffoldSvelteComponentSession({
id: 'gate0001',
count: 1,
sourceFile: 'src/routes/+page.svelte',
sourceStartLine: 2,
sourceEndLine: 2,
originalLines: [' <div class="pick">hi</div>'],
cwd: tmp3,
});
assert.equal(session.fallback, undefined, session.reason);
// The exact field failure: the agent kept the seeded block and
// appended its own second top-level <style>.
write(tmp3, join(session.componentDir, 'v1.svelte'), `<script>
let {} = $props();
</script>
<div class="pick board">hi</div>
<style>
.pick { color: red; }
</style>
<style>
.board { margin-top: 86px; }
</style>
`);
const broken = compileCheckVariants('gate0001', tmp3);
assert.equal(broken.ok, false);
assert.equal(broken.checked, 1);
assert.match(broken.failures[0].message, /single top-level/);
assert.match(broken.failures[0].file, /gate0001\/v1\.svelte/);
assert.equal(typeof broken.failures[0].line, 'number');
// Merged into one block: the gate opens.
write(tmp3, join(session.componentDir, 'v1.svelte'), `<script>
let {} = $props();
</script>
<div class="pick board">hi</div>
<style>
.pick { color: red; }
.board { margin-top: 86px; }
</style>
`);
const fixed = compileCheckVariants('gate0001', tmp3);
assert.equal(fixed.ok, true, JSON.stringify(fixed.failures));
} finally {
rmSync(tmp3, { recursive: true, force: true });
}
});
});