Files
pbakaus_impeccable/skill/scripts/live/roots.mjs
T
Paul BakausandClaude Code 17dabf4b7e 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>
2026-07-27 15:09:40 -07:00

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;
}