mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 08:36:25 +03:00
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:
co-authored by
Claude Code
parent
da68678e7e
commit
dc5420b64f
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user