/*
RFC-0267: the WorkspaceIO port — the single seam through which kernel commands
touch the filesystem and spawn processes. `KernelRuntimeContext.io` carries an
adapter chosen by the executor: the default fs-backed adapter, a recording
adapter (universal `--dry-run`: intercepts writes/deletes, records intents,
touches nothing), or a read-only adapter (used for `mutatesState: false`
commands; throws KERNEL-META-01 on any attempted mutation). Adoption is
ratcheted — ambient `node:fs` imports keep working in unmigrated modules;
`kernel.io.lint` (IO-01) only gates new/migrated modules.
RFC-0326: `createDefaultIO` now returns `{ io, intents }` — the io writes to
real disk AND records every mutation as a `WriteIntent` for execution-report
surfacing (`filesModified`). This unifies the dry-run and real-run paths: both
capture intents, just from different adapters.
- Do not abstract network access — filesystem and process execution only.
- Do not enforce IO at the module-import level — that is kernel.io.lint's static check.
- WorkspaceIO is the single filesystem seam — kernel commands never touch fs directly.
- RFC-0326: createDefaultIO returns { io, intents } — writes touch real disk AND record WriteIntent[] for filesModified reporting.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
- RFC-1159: step 1 — execOrThrow helper in workspace-io
- RFC-1306 review-r1: writeFileIfChanged — io-port idempotent write (read-compare-skip); filesModified reports only real byte changes.
RFC-0267
*/
import {
readFile as fsReadFile,
mkdir as fsMkdir,
rm as fsRm,
readdir as fsReaddir,
stat,
rename as fsRename,
appendFile as fsAppendFile,
copyFile as fsCopyFile,
mkdtemp as fsMkdtemp,
realpath as fsRealpath,
} from "node:fs/promises";
import { glob as fsGlob } from "node:fs/promises";
import { spawn } from "node:child_process";
import { tmpdir as osTmpdir } from "node:os";
import { dirname, resolve as pathResolve, sep } from "node:path";
import { writeFileAtomic } from "./fs-atomic.ts";
export interface ExecOptions {
cwd?: string;
timeoutMs?: number;
/** Extra environment variables merged over `process.env` for the child. */
env?: Record;
}
export interface ExecResult {
exitCode: number | null;
stdout: string;
stderr: string;
}
/**
* A directory entry in a port-neutral shape — no `node:fs` `Dirent` leaks
* through the port, so command modules never need to import `node:fs` just to
* type a listing. `isFile`/`isDirectory` are plain booleans (not methods).
*/
export interface DirEntry {
name: string;
isFile: boolean;
isDirectory: boolean;
}
/** Port-neutral `stat` result — no `node:fs` `Stats` leaks through the port. */
export interface FileStat {
isFile: boolean;
isDirectory: boolean;
mtimeMs: number;
size: number;
}
export interface WorkspaceIO {
readFile(path: string): Promise;
readFileBytes(path: string): Promise;
exists(path: string): Promise;
glob(pattern: string, opts?: { cwd?: string }): Promise;
/** List a directory's immediate entries. Read-only; throws if the path is not a directory. */
readdir(path: string): Promise;
/** File metadata in a port-neutral shape. Throws when the path does not exist. */
stat(path: string): Promise;
/** Resolve symlinks to the canonical absolute path. Read-only. */
realpath(path: string): Promise;
/** Atomic by contract (rfc-0258). */
writeFile(path: string, content: string | Uint8Array): Promise;
/** Append content to a file, creating it (and parent dirs) when absent. */
appendFile(path: string, content: string | Uint8Array): Promise;
mkdir(path: string): Promise;
rm(path: string, opts?: { recursive?: boolean }): Promise;
rename(src: string, dst: string): Promise;
copyFile(src: string, dst: string): Promise;
/** Create a unique temporary directory under `prefix`; returns its path. */
mkdtemp(prefix: string): Promise;
exec(command: string, args: string[], opts?: ExecOptions): Promise;
/**
* RFC-1176: `true` only on the read-only adapter (`mutatesState: false`
* commands). Best-effort bookkeeping writes must be skipped with
* `if (!io.readonly)` instead of attempting a mutation that throws
* KERNEL-META-01 — the throw stays as the enforcement floor for
* undeclared mutations. Undefined on real and recording adapters.
*/
readonly readonly?: boolean;
}
async function execImpl(command: string, args: string[], opts?: ExecOptions): Promise {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: opts?.cwd,
env: opts?.env ? { ...process.env, ...opts.env } : undefined,
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk) => {
stderr += chunk;
});
const timer = opts?.timeoutMs
? setTimeout(() => {
child.kill();
}, opts.timeoutMs)
: undefined;
timer?.unref?.();
child.on("close", (exitCode) => {
if (timer) clearTimeout(timer);
resolve({ exitCode, stdout, stderr });
});
child.on("error", (error) => {
if (timer) clearTimeout(timer);
reject(error);
});
});
}
/**
* RFC-0326: the default, real fs-backed adapter. `writeFile` is atomic
* (rfc-0258). Now returns `{ io, intents }` matching `createRecordingIO`'s
* shape — the io writes to real disk; intents captures every mutation for
* `filesModified` reporting on execution reports.
*/
export function createDefaultIO(): { io: WorkspaceIO; intents: WriteIntent[] } {
const intents: WriteIntent[] = [];
const io: WorkspaceIO = {
async readFile(path) {
return fsReadFile(path, "utf8");
},
async readFileBytes(path) {
return fsReadFile(path);
},
async exists(path) {
try {
await stat(path);
return true;
} catch {
return false;
}
},
async glob(pattern, opts) {
const results: string[] = [];
for await (const entry of fsGlob(pattern, { cwd: opts?.cwd })) {
results.push(entry);
}
return results;
},
async readdir(path) {
const entries = await fsReaddir(path, { withFileTypes: true });
return entries.map((entry) => ({
name: entry.name,
isFile: entry.isFile(),
isDirectory: entry.isDirectory(),
}));
},
async writeFile(path, content) {
await fsMkdir(dirname(path), { recursive: true });
await writeFileAtomic(path, content);
const bytes =
typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
intents.push({ kind: "write", path, bytes });
},
async mkdir(path) {
await fsMkdir(path, { recursive: true });
intents.push({ kind: "mkdir", path });
},
async rm(path, opts) {
await fsRm(path, { recursive: opts?.recursive ?? false, force: true });
intents.push({ kind: "rm", path });
},
async stat(path) {
const s = await stat(path);
return { isFile: s.isFile(), isDirectory: s.isDirectory(), mtimeMs: s.mtimeMs, size: s.size };
},
async realpath(path) {
return fsRealpath(path);
},
async appendFile(path, content) {
await fsMkdir(dirname(path), { recursive: true });
await fsAppendFile(path, content);
const bytes =
typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
intents.push({ kind: "write", path, bytes });
},
async rename(src, dst) {
await fsMkdir(dirname(dst), { recursive: true });
await fsRename(src, dst);
intents.push({ kind: "write", path: dst });
},
async copyFile(src, dst) {
await fsMkdir(dirname(dst), { recursive: true });
await fsCopyFile(src, dst);
intents.push({ kind: "write", path: dst });
},
async mkdtemp(prefix) {
const dir = await fsMkdtemp(prefix);
intents.push({ kind: "mkdir", path: dir });
return dir;
},
exec: execImpl,
};
return { io, intents };
}
export interface WriteIntent {
kind: "write" | "mkdir" | "rm";
path: string;
bytes?: number;
/** RFC-1149 follow-up: full text content recorded under --dry-run so the executor can surface `renderedFiles` for generated.drift.validate (DRIFT-02). Absent for binary writes and path-only intents. */
content?: string;
}
/**
* Wraps a base adapter for universal `--dry-run`: reads pass through to the
* base adapter (so a command can still make write decisions based on real
* state), but writes/mkdir/rm are intercepted, recorded, and never touch disk.
*/
export function createRecordingIO(base: WorkspaceIO): { io: WorkspaceIO; intents: WriteIntent[] } {
const intents: WriteIntent[] = [];
// RFC-1149 follow-up: accumulate rendered text content per path so the last
// write intent for a path carries the full file — powers renderedFiles.
const contents = new Map();
const io: WorkspaceIO = {
readFile: base.readFile.bind(base),
readFileBytes: base.readFileBytes.bind(base),
exists: base.exists.bind(base),
glob: base.glob.bind(base),
readdir: base.readdir.bind(base),
stat: base.stat.bind(base),
realpath: base.realpath.bind(base),
async writeFile(path, content) {
const bytes =
typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
const text = typeof content === "string" ? content : undefined;
if (text === undefined) contents.delete(path);
else contents.set(path, text);
intents.push({ kind: "write", path, bytes, content: text });
},
async appendFile(path, content) {
const bytes =
typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength;
// Append accumulates onto content recorded earlier this run; appending
// to a path never written stays unverifiable — same as before.
const prior = contents.get(path);
const combined =
prior !== undefined && typeof content === "string" ? prior + content : undefined;
if (combined === undefined) contents.delete(path);
else contents.set(path, combined);
intents.push({ kind: "write", path, bytes, content: combined });
},
async mkdir(path) {
intents.push({ kind: "mkdir", path });
},
async rm(path) {
contents.delete(path);
intents.push({ kind: "rm", path });
},
async rename(_src, dst) {
contents.delete(dst);
intents.push({ kind: "write", path: dst });
},
async copyFile(_src, dst) {
contents.delete(dst);
intents.push({ kind: "write", path: dst });
},
async mkdtemp(prefix) {
const dir = `${prefix}dry-run`;
intents.push({ kind: "mkdir", path: dir });
return dir;
},
async exec() {
throw new Error("createRecordingIO: exec() is not supported under --dry-run.");
},
};
return { io, intents };
}
/**
* RFC-1159: canonical exit-code check on `io.exec`. `execImpl` resolves
* non-zero exits as data (the `ExecResult` contract) — commands must not let
* a failed side effect masquerade as success (DNA-106). Use this for
* failure-semantics calls (git commit/add/reset, mv, exiftool, …); pass
* `allowExitCodes` for signal-semantics calls where a non-zero exit is the
* answer, not a failure (`git diff --quiet`, `git rev-parse --verify`).
*/
export async function execOrThrow(
io: WorkspaceIO,
command: string,
args: string[],
opts?: ExecOptions & { allowExitCodes?: number[] },
): Promise {
const { allowExitCodes, ...execOpts } = opts ?? {};
const result = await io.exec(command, args, execOpts);
const code = result.exitCode;
if (code !== 0 && !(code !== null && allowExitCodes?.includes(code))) {
const sub = args[0] ? ` ${args[0]}` : "";
const detail = result.stderr.trim() || result.stdout.trim() || "(no output)";
throw new Error(`${command}${sub} failed (exit ${code ?? "null"}): ${detail}`);
}
return result;
}
/**
* RFC-1306: idempotent write through the io port — reads the current content
* and calls `io.writeFile` only when bytes differ. A no-op write records no
* intent, so `filesModified` reports "changed" honestly (zero-diff regens
* report zero files). io.writeFile already mkdirs + writes atomically.
* Returns true when the file was (re)written.
*/
export async function writeFileIfChanged(
io: WorkspaceIO,
path: string,
content: string | Uint8Array,
): Promise {
try {
if (typeof content === "string") {
if ((await io.readFile(path)) === content) return false;
} else {
const existing = await io.readFileBytes(path);
if (existing.length === content.length && Buffer.from(existing).equals(content)) return false;
}
} catch {
// Missing/unreadable target — fall through to write.
}
await io.writeFile(path, content);
return true;
}
export class KernelMetaError extends Error {
constructor(
readonly commandName: string,
readonly attemptedPath: string,
) {
super(
`KERNEL-META-01: command "${commandName}" declares mutatesState: false but attempted to write "${attemptedPath}". ` +
`Fix the command, or if the write is legitimate, set mutatesState: true and declare the path in writes (RFC-0266).`,
);
this.name = "KernelMetaError";
}
}
export interface ReadOnlyIOOptions {
/**
* RFC-1176: pass `command.execOnReadOnly === true` — the command declared
* subprocess use, so `exec` is bound through instead of throwing. All
* filesystem mutations still throw KERNEL-META-01.
*/
allowExec?: boolean;
/**
* RFC-1176: the workspace root — the tmpdir carve-out below must NOT apply
* to paths inside it (a workshop itself may live under $TMPDIR, e.g. test
* fixtures and CI smoke workspaces). When absent, any tmpdir path passes.
*/
workspaceRoot?: string;
/**
* RFC-1303: workspace-relative directory prefixes the command declared via
* `cacheWrites` — writes resolving under them delegate to the wrapped
* adapter instead of throwing. Requires `workspaceRoot`; without it the
* prefixes cannot resolve and every write still throws KERNEL-META-01.
* Entries are cache-class paths only — CACHE-WRITE-01 rejects git-tracked
* prefixes at declaration-validation time.
*/
cacheWrites?: string[];
}
/**
* Wraps a base adapter so every mutation throws KERNEL-META-01 naming the
* command and the attempted path. Used for every `mutatesState: false`
* command (proves the metadata trustworthy) and by the test harness.
*
* RFC-1176 carve-outs: mutations targeting paths under `os.tmpdir()` pass
* through — scratch space is not workspace state — unless the path is
* inside `opts.workspaceRoot` (workshops can live under $TMPDIR). `exec`
* passes through when `opts.allowExec` (the command declared
* `execOnReadOnly`). `io.readonly` reports `true` so commands can skip
* best-effort writes instead of relying on the throw.
*/
export function createReadOnlyIO(
base: WorkspaceIO,
commandName: string,
opts?: ReadOnlyIOOptions,
): WorkspaceIO {
const tmpRoots = [osTmpdir(), pathResolve(osTmpdir())].map((r) =>
r.endsWith(sep) ? r : r + sep,
);
const workspaceRoot = opts?.workspaceRoot ? pathResolve(opts.workspaceRoot) : undefined;
const isTmpPath = (p: string) => {
const abs = pathResolve(p);
if (workspaceRoot && (abs === workspaceRoot || abs.startsWith(workspaceRoot + sep)))
return false;
return tmpRoots.some((root) => abs.startsWith(root));
};
// RFC-1303: declared cache-write prefixes resolve under the workspace root;
// a write landing inside one delegates to the real adapter.
const cachePrefixes = (opts?.cacheWrites ?? [])
.map((prefix) => (workspaceRoot ? pathResolve(workspaceRoot, prefix) : undefined))
.filter((p): p is string => p !== undefined)
.map((p) => (p.endsWith(sep) ? p : p + sep));
const isCacheWritePath = (p: string) =>
cachePrefixes.length > 0 &&
cachePrefixes.some((root) => {
const abs = pathResolve(p);
// Equal match allows mkdir/writeFile on the prefix dir itself.
return abs === root.slice(0, -sep.length) || abs.startsWith(root);
});
const mayWrite = (p: string) => isTmpPath(p) || isCacheWritePath(p);
return {
readFile: base.readFile.bind(base),
readFileBytes: base.readFileBytes.bind(base),
exists: base.exists.bind(base),
glob: base.glob.bind(base),
readdir: base.readdir.bind(base),
stat: base.stat.bind(base),
realpath: base.realpath.bind(base),
readonly: true,
async writeFile(path, content) {
if (mayWrite(path)) return base.writeFile(path, content);
throw new KernelMetaError(commandName, path);
},
async appendFile(path, content) {
if (mayWrite(path)) return base.appendFile(path, content);
throw new KernelMetaError(commandName, path);
},
async mkdir(path) {
if (mayWrite(path)) return base.mkdir(path);
throw new KernelMetaError(commandName, path);
},
async rm(path, rmOpts) {
if (mayWrite(path)) return base.rm(path, rmOpts);
throw new KernelMetaError(commandName, path);
},
async rename(src, dst) {
if (mayWrite(dst)) return base.rename(src, dst);
throw new KernelMetaError(commandName, dst);
},
async copyFile(src, dst) {
if (mayWrite(dst)) return base.copyFile(src, dst);
throw new KernelMetaError(commandName, dst);
},
async mkdtemp(prefix) {
if (mayWrite(prefix)) return base.mkdtemp(prefix);
throw new KernelMetaError(commandName, prefix);
},
exec: opts?.allowExec
? base.exec.bind(base)
: async (command, args) => {
throw new KernelMetaError(commandName, `${command} ${args.join(" ")}`);
},
};
}