mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 09:06:53 +03:00
Live v2: root manifest, mount-ack protocol, AST scaffolder, mechanical accept
A ground-up hardening of live mode, driven by a production session in a nested-app monorepo that hit six distinct failure classes. Full design rationale in docs/LIVE-REWRITE-PLAN.md; every Codex-reported failure now has a mechanical fix and a regression test. Roots: live/roots.mjs resolves appRoot/repoRoot/contextRoot once at boot (keyed on dev-server configs, not monorepo brand markers), persists a manifest, and every live CLI re-anchors onto it at startup, so a helper run from the wrong directory can no longer fork session state. Context files are discovered upward to the git root. Render truth: variant_mounted / variant_mount_failed events give the journal per-variant mount state; failures reach the agent's poll queue, raise a persistent error card with Retry (no more localStorage wipe), and an attach probe names root/dev-server mismatches explicitly. The browser rehydrates from the server when localStorage is gone. Svelte: the scaffolder now parses with the app's own svelte 5 compiler. Control flow survives (an each collection crosses the contract as one structured prop), keyed each blocks hydrate synthetic keys, and anything a detached preview cannot support falls back to source-preview instead of shipping a wrong scaffold. Preview modules live in per-publish revision directories, defeating stale transform caches. Accept: CSS is reconciled, not appended. Matching selectors are replaced, params bake from params.json kinds, the compiler's unused-selector pass prunes superseded rules (pre-existing dead rules protected), a selector- loss postcondition refuses any write that would drop hand-written rules, and live-complete refuses to finish while live plumbing remains in source. Also: framework registry (live/frameworks/) with a crash-safe injection journal, session-store snapshot caching with read-only reads, protocol enum consolidation, steer Send button, honest DESIGN-panel empty states. Testing: new unit suites (roots, AST scaffolder, accept CSS, accept pipeline, framework conformance); e2e now fails on preview-tree 404s, proves computed-style mount for every variant, drives the Tune panel through baked params, and injects failures (broken mounts, republish, storage loss). New runtime fixtures: monorepo-nested-vite (repo root != app root) and vite8-sveltekit-stateful (each blocks + state). Nightly full-matrix cron. An independent adversarial review pass preceded this commit; its blocker and major findings are fixed and regression-tested. 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
839dd10079
commit
17dabf4b7e
+49
-31
@@ -17,11 +17,13 @@ import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs';
|
||||
import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
|
||||
import { findSourceFile } from './live/source-search.mjs';
|
||||
import { resolveSourceTraits } from './live/frameworks/index.mjs';
|
||||
import {
|
||||
buildSvelteComponentCssAuthoring,
|
||||
scaffoldSvelteComponentSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
import { enterLiveRoot } from './live/roots.mjs';
|
||||
|
||||
export async function wrapCli() {
|
||||
const args = process.argv.slice(2);
|
||||
@@ -293,8 +295,10 @@ The agent should insert variant HTML at insertLine.`);
|
||||
.join('\n');
|
||||
const originalIndented = reindentOriginal(' ');
|
||||
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
|
||||
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
|
||||
const useFrameworkComponent = useSvelteComponent;
|
||||
// The registry says which files get component preview; the svelte-component
|
||||
// module keeps the env escape hatch that turns it off.
|
||||
const useSvelteComponent = resolveSourceTraits(targetFile).preview === 'component'
|
||||
&& shouldUseSvelteComponentInjection(targetFile);
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
@@ -343,12 +347,18 @@ The agent should insert variant HTML at insertLine.`);
|
||||
let svelteSession = null;
|
||||
let deferredWrapper = null;
|
||||
|
||||
let sveltePreviewFallback = null;
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
// Keep generation source-neutral: agents write real variant components
|
||||
// under the generated componentDir, the browser mounts them into the live
|
||||
// DOM, and live-accept.mjs inlines the accepted variant back into the route.
|
||||
svelteSession = scaffoldSvelteComponentSession({
|
||||
//
|
||||
// The scaffold is AST-based and refuses markup a detached preview cannot
|
||||
// support (component tags, bind:/use:, await blocks, bound nested each).
|
||||
// Refusal falls back to the plain source-preview wrapper below: an
|
||||
// HMR-resetting but CORRECT preview beats a detached wrong one.
|
||||
const scaffolded = scaffoldSvelteComponentSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile: relTargetFile,
|
||||
@@ -357,10 +367,18 @@ The agent should insert variant HTML at insertLine.`);
|
||||
originalLines,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
if (scaffolded && scaffolded.fallback === 'source-preview') {
|
||||
sveltePreviewFallback = scaffolded.reason || 'unsupported markup';
|
||||
} else {
|
||||
svelteSession = scaffolded;
|
||||
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
}
|
||||
}
|
||||
if (svelteSession) {
|
||||
// component preview: outputs already set above
|
||||
} else if (deferSourceWrite) {
|
||||
// Deferred source write: compute the scaffold text but leave source
|
||||
// untouched. The agent replaces the picked element's source range with
|
||||
@@ -396,15 +414,19 @@ The agent should insert variant HTML at insertLine.`);
|
||||
|
||||
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
|
||||
|
||||
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
|
||||
const componentPreviewActive = !!svelteSession;
|
||||
const svelteComponentAuthoring = componentPreviewActive ? buildSvelteComponentCssAuthoring(count) : null;
|
||||
const componentSession = svelteSession;
|
||||
const componentPreviewMode = useSvelteComponent ? 'svelte-component' : undefined;
|
||||
const componentPreviewMode = componentPreviewActive ? 'svelte-component' : undefined;
|
||||
const previewMode = componentPreviewMode;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
file: outputRelFile,
|
||||
sourceFile: useFrameworkComponent ? relTargetFile : undefined,
|
||||
sourceFile: componentPreviewActive ? relTargetFile : undefined,
|
||||
previewMode,
|
||||
previewFallback: sveltePreviewFallback
|
||||
? { from: 'svelte-component', reason: sveltePreviewFallback }
|
||||
: undefined,
|
||||
// Deferred source write: the wrapper is NOT yet in source. The agent
|
||||
// replaces [replaceStartLine, replaceEndLine] with `wrapperBlock` (variants
|
||||
// spliced at the "insert below this line" marker) in one atomic edit.
|
||||
@@ -414,8 +436,8 @@ The agent should insert variant HTML at insertLine.`);
|
||||
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
|
||||
componentDir: componentSession?.componentDir,
|
||||
propContract: componentSession?.propContract,
|
||||
sourceStartLine: useFrameworkComponent ? startLine + 1 : undefined,
|
||||
sourceEndLine: useFrameworkComponent ? endLine + 1 : undefined,
|
||||
sourceStartLine: componentPreviewActive ? startLine + 1 : undefined,
|
||||
sourceEndLine: componentPreviewActive ? endLine + 1 : undefined,
|
||||
startLine: outputStartLine, // 1-indexed for the agent
|
||||
// wrapperLines is an array but one element (the original-content slot)
|
||||
// is a `\n`-joined multi-line string, so the actual file-row count is
|
||||
@@ -426,8 +448,8 @@ The agent should insert variant HTML at insertLine.`);
|
||||
insertLine, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: componentPreviewMode || styleMode.mode,
|
||||
styleTag: useFrameworkComponent ? null : styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: useFrameworkComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
styleTag: componentPreviewActive ? null : styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: componentPreviewActive ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: svelteComponentAuthoring || buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
@@ -630,27 +652,22 @@ function attrEscapeDouble(str) {
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment syntax, style mode, and preview strategy all come from the framework
|
||||
* registry, keyed on the target file's extension: `.jsx`/`.tsx` author JSX
|
||||
* comments, `.astro` needs global-prefixed preview CSS because Astro scopes
|
||||
* component styles away from the generated wrappers, `.svelte` gets component
|
||||
* preview. See live/frameworks/index.mjs for why extension and not project.
|
||||
*/
|
||||
function detectCommentSyntax(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.jsx' || ext === '.tsx') {
|
||||
return { open: '{/*', close: '*/}' };
|
||||
}
|
||||
// HTML, Vue, Svelte, Astro all use HTML comments
|
||||
return { open: '<!--', close: '-->' };
|
||||
return resolveSourceTraits(filePath).commentSyntax === 'jsx'
|
||||
? { open: '{/*', close: '*/}' }
|
||||
: { open: '<!--', close: '-->' };
|
||||
}
|
||||
|
||||
function detectStyleMode(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.astro') {
|
||||
return {
|
||||
mode: 'astro-global-prefixed',
|
||||
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: 'scoped',
|
||||
styleTag: '<style data-impeccable-css="SESSION_ID">',
|
||||
};
|
||||
const traits = resolveSourceTraits(filePath);
|
||||
return { mode: traits.styleMode, styleTag: traits.styleTag };
|
||||
}
|
||||
|
||||
function buildCssSelectorPrefixExamples(styleMode, count) {
|
||||
@@ -890,6 +907,7 @@ function findClosingLine(lines, start) {
|
||||
// Auto-execute when run directly (node live-wrap.mjs ...)
|
||||
const _running = process.argv[1];
|
||||
if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/')) {
|
||||
enterLiveRoot();
|
||||
wrapCli();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user