mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 16:46:31 +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
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Crash-safe injection journal.
|
||||
*
|
||||
* Injection writes into the user's source tree: generated components, a Nuxt
|
||||
* client plugin, marker blocks inside a layout, a patched CSP meta tag. The
|
||||
* clean path removes all of it on stop. The unclean paths do not:
|
||||
*
|
||||
* - the dev server is SIGKILLed, so `--remove` never runs;
|
||||
* - the project changes shape between start and stop (a nuxt.config appears,
|
||||
* a package.json is edited), so detection resolves a different framework
|
||||
* and the old framework's artifacts are nobody's business;
|
||||
* - stop runs from a different directory than start did.
|
||||
*
|
||||
* So every inject records what it wrote to `.impeccable/live/inject-journal.json`
|
||||
* before the next one runs, and both inject and `--remove` reconcile that
|
||||
* record against the tree.
|
||||
*
|
||||
* **The journal is a claim of ownership, not a to-do list.** Healing an
|
||||
* artifact only ever removes what still carries our marker; a generated file
|
||||
* the user has since replaced, or a layout they have since un-patched by hand,
|
||||
* is dropped from the journal untouched.
|
||||
*
|
||||
* **Path resolution is appRoot-relative.** Live entry scripts chdir onto the
|
||||
* roots manifest (`enterLiveRoot`) before doing anything, so a journal written
|
||||
* by a session started in the app root is found by a stop issued from any
|
||||
* directory inside the repo.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { PATCH_UNDOERS } from './index.mjs';
|
||||
|
||||
export const INJECT_JOURNAL_VERSION = 1;
|
||||
export const INJECT_JOURNAL_RELPATH = '.impeccable/live/inject-journal.json';
|
||||
|
||||
export function injectJournalPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, ...INJECT_JOURNAL_RELPATH.split('/'));
|
||||
}
|
||||
|
||||
export function readInjectJournal(cwd = process.cwd()) {
|
||||
const file = injectJournalPath(cwd);
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.artifacts)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function clearInjectJournal(cwd = process.cwd()) {
|
||||
try { fs.unlinkSync(injectJournalPath(cwd)); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
function writeInjectJournal(cwd, journal) {
|
||||
const file = injectJournalPath(cwd);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify(journal, null, 2) + '\n', 'utf-8');
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the artifacts an injection just wrote. Replaces any previous record:
|
||||
* callers heal first (see healInjectJournal), so nothing survivable is lost.
|
||||
*/
|
||||
export function recordInjection(cwd = process.cwd(), { framework, port, artifacts = [] } = {}) {
|
||||
if (!artifacts.length) {
|
||||
clearInjectJournal(cwd);
|
||||
return null;
|
||||
}
|
||||
return writeInjectJournal(cwd, {
|
||||
version: INJECT_JOURNAL_VERSION,
|
||||
appRoot: path.resolve(cwd),
|
||||
framework: framework || null,
|
||||
port: Number.isFinite(Number(port)) ? Number(port) : null,
|
||||
pid: process.pid,
|
||||
recordedAt: new Date().toISOString(),
|
||||
artifacts,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRel(cwd, rel) {
|
||||
return path.resolve(cwd, String(rel || '')).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function readIfPresent(abs) {
|
||||
try {
|
||||
return fs.readFileSync(abs, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEmptyDirs(dir, stopDir) {
|
||||
let current = path.resolve(dir);
|
||||
const stop = path.resolve(stopDir);
|
||||
while (current !== stop && current.startsWith(stop + path.sep)) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
current = path.dirname(current);
|
||||
}
|
||||
}
|
||||
|
||||
function insideProject(cwd, abs) {
|
||||
const rel = path.relative(path.resolve(cwd), path.resolve(abs));
|
||||
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function healArtifact(cwd, artifact, undoers) {
|
||||
const abs = path.resolve(cwd, artifact.path);
|
||||
// The journal is a project-local file, i.e. attacker-writable input in a
|
||||
// cloned repo. Never touch anything outside the project tree, whatever the
|
||||
// journal claims to own.
|
||||
if (!insideProject(cwd, abs)) return { path: artifact.path, action: 'refused_outside_project' };
|
||||
const content = readIfPresent(abs);
|
||||
if (content === null) return { path: artifact.path, action: 'absent' };
|
||||
|
||||
if (artifact.kind === 'created') {
|
||||
// Only reclaim a generated file that still carries our marker; a created
|
||||
// artifact with no marker at all is unverifiable and stays untouched.
|
||||
if (!artifact.marker || !content.includes(artifact.marker)) {
|
||||
return { path: artifact.path, action: 'disowned' };
|
||||
}
|
||||
try { fs.rmSync(abs, { force: true }); } catch { return null; }
|
||||
if (artifact.pruneTo !== undefined) {
|
||||
const pruneRoot = path.resolve(cwd, artifact.pruneTo || '.');
|
||||
if (insideProject(cwd, pruneRoot) || pruneRoot === path.resolve(cwd)) {
|
||||
pruneEmptyDirs(path.dirname(abs), pruneRoot);
|
||||
}
|
||||
}
|
||||
return { path: artifact.path, action: 'removed' };
|
||||
}
|
||||
|
||||
if (artifact.kind === 'patched') {
|
||||
const markers = Array.isArray(artifact.markers) ? artifact.markers : [];
|
||||
// No marker left means the patch is already gone; never run an undo over
|
||||
// a file we no longer recognize (the undoers normalize whitespace).
|
||||
if (markers.length && !markers.some((marker) => content.includes(marker))) {
|
||||
return { path: artifact.path, action: 'disowned' };
|
||||
}
|
||||
const undo = undoers[artifact.patch];
|
||||
if (typeof undo !== 'function') return null;
|
||||
const next = undo(content);
|
||||
if (next === content) return { path: artifact.path, action: 'disowned' };
|
||||
try { fs.writeFileSync(abs, next, 'utf-8'); } catch { return null; }
|
||||
return { path: artifact.path, action: 'unpatched' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the journal against the tree.
|
||||
*
|
||||
* `keep` is the set of paths the current operation legitimately owns — the
|
||||
* artifacts an inject is about to (re)write. Everything else in the journal is
|
||||
* an orphan of a session that is gone, and gets healed. This keeps a repeat
|
||||
* inject byte-idempotent: the artifacts it is about to rewrite are kept, not
|
||||
* torn down and rebuilt.
|
||||
*
|
||||
* Returns `{ healed, kept }`. `healed` lists only artifacts whose file was
|
||||
* actually changed or removed, so callers can stay silent when nothing was
|
||||
* orphaned. Idempotent: a second call finds an empty journal.
|
||||
*/
|
||||
export function healInjectJournal(cwd = process.cwd(), { keep = [], undoers = PATCH_UNDOERS } = {}) {
|
||||
const journal = readInjectJournal(cwd);
|
||||
if (!journal) return { healed: [], kept: [] };
|
||||
|
||||
const keepSet = new Set(keep.map((rel) => normalizeRel(cwd, rel)));
|
||||
const healed = [];
|
||||
const kept = [];
|
||||
|
||||
for (const artifact of journal.artifacts) {
|
||||
if (!artifact || typeof artifact.path !== 'string') continue;
|
||||
if (keepSet.has(normalizeRel(cwd, artifact.path))) {
|
||||
kept.push(artifact);
|
||||
continue;
|
||||
}
|
||||
const outcome = healArtifact(cwd, artifact, undoers);
|
||||
if (outcome && (outcome.action === 'removed' || outcome.action === 'unpatched')) {
|
||||
healed.push(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
if (kept.length) {
|
||||
writeInjectJournal(cwd, { ...journal, artifacts: kept });
|
||||
} else {
|
||||
clearInjectJournal(cwd);
|
||||
}
|
||||
|
||||
return { healed, kept };
|
||||
}
|
||||
Reference in New Issue
Block a user