mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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>
330 lines
12 KiB
JavaScript
330 lines
12 KiB
JavaScript
/**
|
|
* Live root resolution: the single place that decides which directories a live
|
|
* session operates on. Every live entry script resolves this once at startup
|
|
* (see enterLiveRoot) instead of trusting its ambient cwd, which is how a
|
|
* `cd` used to silently fork the whole system into a second, empty project.
|
|
*
|
|
* Four distinct roots travel together as one manifest:
|
|
*
|
|
* appRoot what the dev server serves; where live session state,
|
|
* injected adapters, and preview modules live.
|
|
* repoRoot the git boundary (falls back to appRoot outside git).
|
|
* contextRoot the nearest directory from appRoot up to repoRoot carrying
|
|
* PRODUCT.md / DESIGN.md (canonical spot or a fallback dir).
|
|
* sessionRoot <appRoot>/.impeccable/live — durable live state.
|
|
*
|
|
* appRoot detection keys on dev-server config presence (vite/svelte/next/
|
|
* astro/nuxt/... config files), not on monorepo brand markers. A nested
|
|
* website/ with vite.config.js wins over a repo root that merely has a
|
|
* package.json. Workspace declarations are one input, not the gatekeeper.
|
|
*
|
|
* The resolved manifest is persisted at <appRoot>/.impeccable/live/roots.json
|
|
* plus a pointer at <repoRoot>/.impeccable/live/app-root.json when the two
|
|
* differ, so a helper invoked from anywhere inside the repo finds the same
|
|
* roots the boot decided on. When several apps in one repo run live, the
|
|
* pointer follows the most recent boot; per-app roots.json files stay put.
|
|
*/
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { resolveProjectRoot } from '../context.mjs';
|
|
|
|
const ROOTS_MANIFEST_VERSION = 1;
|
|
const ROOTS_FILE = 'roots.json';
|
|
const POINTER_FILE = 'app-root.json';
|
|
|
|
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
|
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
|
const CONTEXT_FALLBACK_DIRS = ['.agents/context', 'docs'];
|
|
|
|
// Presence of any of these marks a directory as a dev-served app root.
|
|
const DEV_CONFIG_MARKERS = [
|
|
'vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.mts', 'vite.config.cjs',
|
|
'svelte.config.js', 'svelte.config.mjs', 'svelte.config.ts',
|
|
'next.config.js', 'next.config.mjs', 'next.config.ts',
|
|
'astro.config.mjs', 'astro.config.js', 'astro.config.ts', 'astro.config.cjs',
|
|
'nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs',
|
|
'remix.config.js', 'react-router.config.ts',
|
|
'angular.json',
|
|
'webpack.config.js', 'webpack.config.ts',
|
|
];
|
|
|
|
const CANDIDATE_SCAN_IGNORED = new Set([
|
|
'node_modules', '.git', 'dist', 'build', 'coverage', 'vendor', 'vendors',
|
|
'.next', '.nuxt', '.svelte-kit', '.astro', '.turbo', '.cache', '.vercel',
|
|
]);
|
|
const CANDIDATE_SCAN_DEPTH = 2;
|
|
|
|
function exists(p) {
|
|
try { fs.statSync(p); return true; } catch { return false; }
|
|
}
|
|
|
|
function isDir(p) {
|
|
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
}
|
|
|
|
function firstExisting(dir, names) {
|
|
for (const name of names) {
|
|
const abs = path.join(dir, name);
|
|
if (exists(abs)) return abs;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function hasDevConfig(dir) {
|
|
if (DEV_CONFIG_MARKERS.some((name) => exists(path.join(dir, name)))) return true;
|
|
// A plain Vite app can run with zero config: index.html + package.json.
|
|
return exists(path.join(dir, 'index.html')) && exists(path.join(dir, 'package.json'));
|
|
}
|
|
|
|
function isAppRoot(dir) {
|
|
// A directory already configured for live IS an app root, dev config or not
|
|
// (plain static multi-page projects have no bundler config).
|
|
return hasDevConfig(dir) || exists(path.join(dir, '.impeccable', 'live', 'config.json'));
|
|
}
|
|
|
|
function findContextFile(dir, names) {
|
|
const direct = firstExisting(dir, names);
|
|
if (direct) return direct;
|
|
for (const rel of CONTEXT_FALLBACK_DIRS) {
|
|
const nested = firstExisting(path.join(dir, rel), names);
|
|
if (nested) return nested;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function findGitRoot(startDir) {
|
|
let dir = path.resolve(startDir);
|
|
const home = path.resolve(os.homedir());
|
|
while (true) {
|
|
if (dir === home) return null;
|
|
if (exists(path.join(dir, '.git'))) return dir;
|
|
const parent = path.dirname(dir);
|
|
if (parent === dir) return null;
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
function walkUp(startDir, upperBound, visit) {
|
|
let dir = path.resolve(startDir);
|
|
const stop = path.resolve(upperBound);
|
|
const home = path.resolve(os.homedir());
|
|
while (true) {
|
|
if (dir === home) return null;
|
|
const hit = visit(dir);
|
|
if (hit) return hit;
|
|
if (dir === stop) return null;
|
|
const parent = path.dirname(dir);
|
|
if (parent === dir) return null;
|
|
dir = parent;
|
|
}
|
|
}
|
|
|
|
function insideOrEqual(candidate, root) {
|
|
const rel = path.relative(path.resolve(root), path.resolve(candidate));
|
|
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
}
|
|
|
|
/**
|
|
* Scan downward (bounded depth) for directories carrying a dev-server config.
|
|
* Used when live boots from a directory that is not itself an app root and no
|
|
* --target narrows the choice: one candidate is auto-picked, several become a
|
|
* selection prompt.
|
|
*/
|
|
export function discoverAppCandidates(rootDir, depth = CANDIDATE_SCAN_DEPTH) {
|
|
const found = [];
|
|
const scan = (dir, remaining) => {
|
|
let entries;
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (entry.name.startsWith('.') || CANDIDATE_SCAN_IGNORED.has(entry.name)) continue;
|
|
const abs = path.join(dir, entry.name);
|
|
if (hasDevConfig(abs)) {
|
|
found.push(abs);
|
|
continue; // nested apps below an app root are that app's business
|
|
}
|
|
if (remaining > 1) scan(abs, remaining - 1);
|
|
}
|
|
};
|
|
scan(path.resolve(rootDir), depth);
|
|
return found.sort();
|
|
}
|
|
|
|
/**
|
|
* Fresh root resolution. Never reads a persisted manifest.
|
|
*
|
|
* Returns { manifest } on success or { selection } when several candidate
|
|
* apps exist and nothing disambiguates.
|
|
*/
|
|
export function resolveRoots({ cwd = process.cwd(), targetPath = null } = {}) {
|
|
const absCwd = path.resolve(cwd);
|
|
const absTarget = targetPath
|
|
? (path.isAbsolute(targetPath) ? targetPath : path.resolve(absCwd, targetPath))
|
|
: null;
|
|
const targetDir = absTarget
|
|
? (isDir(absTarget) ? absTarget : path.dirname(absTarget))
|
|
: absCwd;
|
|
|
|
// The walk bound must be an ancestor of the target: a git root found from
|
|
// the CWD is only usable when the target actually lives inside it,
|
|
// otherwise the walk would climb out of both trees.
|
|
const targetGitRoot = findGitRoot(targetDir);
|
|
const cwdGitRoot = targetGitRoot ? null : findGitRoot(absCwd);
|
|
const repoRoot = targetGitRoot
|
|
|| (cwdGitRoot && insideOrEqual(targetDir, cwdGitRoot) ? cwdGitRoot : null);
|
|
// Without a git boundary, never ascend above the starting directory: the
|
|
// filesystem above an unversioned project is not ours to interpret.
|
|
const upperBound = repoRoot || targetDir;
|
|
|
|
// The workspace-aware legacy resolution (context.mjs) still decides two
|
|
// things: the fallback when no app marker exists, and how far the marker
|
|
// walk may ascend when an explicit target selected a workspace child. A
|
|
// root-level live config must never shadow a child the target picked.
|
|
const legacyRoot = resolveProjectRoot(absCwd, absTarget ? { targetPath: absTarget } : {});
|
|
const markerBound = absTarget && insideOrEqual(targetDir, legacyRoot) && insideOrEqual(legacyRoot, upperBound)
|
|
? legacyRoot
|
|
: upperBound;
|
|
|
|
let appRoot = walkUp(targetDir, markerBound, (dir) => (isAppRoot(dir) ? dir : null));
|
|
let resolvedFrom = appRoot
|
|
? (absTarget ? `target:${path.relative(absCwd, absTarget) || '.'}` : 'cwd')
|
|
: null;
|
|
|
|
if (!appRoot && !absTarget) {
|
|
const candidates = discoverAppCandidates(absCwd);
|
|
if (candidates.length === 1) {
|
|
appRoot = candidates[0];
|
|
resolvedFrom = `candidate:${path.relative(absCwd, appRoot)}`;
|
|
} else if (candidates.length > 1) {
|
|
return {
|
|
selection: {
|
|
candidates: candidates.map((abs) => ({
|
|
name: path.basename(abs),
|
|
path: path.relative(absCwd, abs).split(path.sep).join('/'),
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
if (!appRoot) {
|
|
// No app marker anywhere: defer to the workspace-aware legacy resolution
|
|
// (workspace child for a targeted monorepo path, cwd otherwise). Never
|
|
// adopt an arbitrary ancestor just because it has a package.json, and
|
|
// never adopt a root that does not even contain the target.
|
|
appRoot = insideOrEqual(targetDir, legacyRoot) ? legacyRoot : targetDir;
|
|
resolvedFrom = 'fallback';
|
|
}
|
|
|
|
const effectiveRepoRoot = repoRoot && insideOrEqual(appRoot, repoRoot) ? repoRoot : appRoot;
|
|
|
|
// Each context file resolves independently: a child app may carry its own
|
|
// PRODUCT.md while inheriting DESIGN.md from the repo root (or vice versa).
|
|
const productPath = walkUp(appRoot, effectiveRepoRoot, (dir) => findContextFile(dir, PRODUCT_NAMES));
|
|
const designPath = walkUp(appRoot, effectiveRepoRoot, (dir) => findContextFile(dir, DESIGN_NAMES));
|
|
const contextRoot = productPath
|
|
? path.dirname(productPath)
|
|
: designPath
|
|
? path.dirname(designPath)
|
|
: null;
|
|
|
|
return {
|
|
manifest: {
|
|
version: ROOTS_MANIFEST_VERSION,
|
|
appRoot,
|
|
repoRoot: effectiveRepoRoot,
|
|
contextRoot,
|
|
sessionRoot: path.join(appRoot, '.impeccable', 'live'),
|
|
productPath,
|
|
designPath,
|
|
resolvedFrom,
|
|
},
|
|
};
|
|
}
|
|
|
|
function rootsFilePath(appRoot) {
|
|
return path.join(appRoot, '.impeccable', 'live', ROOTS_FILE);
|
|
}
|
|
|
|
function pointerFilePath(repoRoot) {
|
|
return path.join(repoRoot, '.impeccable', 'live', POINTER_FILE);
|
|
}
|
|
|
|
export function writeRootsManifest(manifest) {
|
|
const file = rootsFilePath(manifest.appRoot);
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, JSON.stringify(manifest, null, 2));
|
|
if (path.resolve(manifest.repoRoot) !== path.resolve(manifest.appRoot)) {
|
|
const pointer = pointerFilePath(manifest.repoRoot);
|
|
fs.mkdirSync(path.dirname(pointer), { recursive: true });
|
|
fs.writeFileSync(pointer, JSON.stringify({ appRoot: manifest.appRoot }));
|
|
}
|
|
return file;
|
|
}
|
|
|
|
function readManifestAt(appRoot) {
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(rootsFilePath(appRoot), 'utf-8'));
|
|
if (!raw || typeof raw.appRoot !== 'string') return null;
|
|
// A manifest is only trusted where it claims to live; anything else is a
|
|
// copied or stale file.
|
|
if (path.resolve(raw.appRoot) !== path.resolve(appRoot)) return null;
|
|
return raw;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the roots for the live session governing `cwd`, preferring a
|
|
* persisted manifest (written by the boot) over fresh detection:
|
|
*
|
|
* 1. Walk up from cwd looking for .impeccable/live/roots.json.
|
|
* 2. At the git root, follow .impeccable/live/app-root.json to the app.
|
|
* 3. Fresh resolveRoots().
|
|
*
|
|
* Fresh results are NOT persisted here; only the boot (live.mjs / server
|
|
* startup) writes manifests, so ad-hoc helper invocations cannot mint
|
|
* conflicting truth.
|
|
*/
|
|
export function resolveLiveRoots(cwd = process.cwd(), { targetPath = null } = {}) {
|
|
const absCwd = path.resolve(cwd);
|
|
|
|
if (!targetPath) {
|
|
const persisted = walkUp(absCwd, findGitRoot(absCwd) || absCwd, (dir) => readManifestAt(dir));
|
|
if (persisted) return { manifest: persisted, source: 'persisted' };
|
|
|
|
const gitRoot = findGitRoot(absCwd);
|
|
if (gitRoot) {
|
|
try {
|
|
const pointer = JSON.parse(fs.readFileSync(pointerFilePath(gitRoot), 'utf-8'));
|
|
if (pointer && typeof pointer.appRoot === 'string') {
|
|
const viaPointer = readManifestAt(pointer.appRoot);
|
|
if (viaPointer) return { manifest: viaPointer, source: 'pointer' };
|
|
}
|
|
} catch { /* no pointer */ }
|
|
}
|
|
}
|
|
|
|
const fresh = resolveRoots({ cwd: absCwd, targetPath });
|
|
if (fresh.selection) return { selection: fresh.selection, source: 'fresh' };
|
|
return { manifest: fresh.manifest, source: 'fresh' };
|
|
}
|
|
|
|
/**
|
|
* Entry-point guard for live CLI scripts: resolve the governing roots and
|
|
* make appRoot the process cwd so every downstream path derivation agrees
|
|
* with the boot. Returns the manifest. Never throws; on selection ambiguity
|
|
* it stays in the current directory (the boot flow handles prompting).
|
|
*/
|
|
export function enterLiveRoot(cwd = process.cwd()) {
|
|
const resolved = resolveLiveRoots(cwd);
|
|
if (!resolved.manifest) return null;
|
|
const appRoot = resolved.manifest.appRoot;
|
|
if (path.resolve(cwd) !== path.resolve(appRoot) && isDir(appRoot)) {
|
|
try { process.chdir(appRoot); } catch { /* keep current cwd */ }
|
|
}
|
|
return resolved.manifest;
|
|
}
|