|
|
@@ -0,0 +1,576 @@
|
|
|
+/**
|
|
|
+ * BuildPipeline — the whole of `oac build`, as a pure function of the canonical tree.
|
|
|
+ *
|
|
|
+ * ─── What it does ───────────────────────────────────────────────────────────────────────
|
|
|
+ *
|
|
|
+ * Load `content/agents/**`, ask each agent which targets it declares, run the matching
|
|
|
+ * adapter, and collect the result as an ordered set of (path, bytes) pairs. Emitting
|
|
|
+ * `registry.json` is folded in as a target of its own, because it is generated from exactly
|
|
|
+ * the same input and must be gated by exactly the same determinism rules.
|
|
|
+ *
|
|
|
+ * ─── Why planning and writing are separate ──────────────────────────────────────────────
|
|
|
+ *
|
|
|
+ * {@link plan} computes what the tree SHOULD contain and touches nothing. {@link write} takes
|
|
|
+ * a plan and reconciles the disk to it. Splitting them is what makes `--check` and `--dry-run`
|
|
|
+ * honest rather than best-effort: they run the identical code path and simply stop before the
|
|
|
+ * write. A `--check` that re-implements the build is a `--check` that eventually disagrees
|
|
|
+ * with it, and CI trusts the wrong one.
|
|
|
+ *
|
|
|
+ * ─── Determinism ────────────────────────────────────────────────────────────────────────
|
|
|
+ *
|
|
|
+ * `oac build && git diff --exit-code` is the gate the refactor rests on (07 Stage 3), so any
|
|
|
+ * per-run variation turns it into a coin flip. Everything ordered here is ordered by CONTENT:
|
|
|
+ * agents arrive from {@link CanonicalAgentLoader} sorted by `oac.id`, targets are iterated in
|
|
|
+ * a declared literal order rather than the authored `targets:` order, and the plan is sorted
|
|
|
+ * by path before it is returned. No clock, no host path, no `readdir` order, no map-insertion
|
|
|
+ * order reaches the output.
|
|
|
+ *
|
|
|
+ * ─── Orphan removal, and why it is manifest-gated ───────────────────────────────────────
|
|
|
+ *
|
|
|
+ * Deleting a source file must delete its generated output, or the subtask-11 CI gate is
|
|
|
+ * defeated silently: the stale file just sits there and `git diff` stays clean. So the build
|
|
|
+ * prunes. But "prune anything under `.opencode/agent/` without a `content/` source" would
|
|
|
+ * delete `.opencode/agent/eval-runner.md`, a real, shipped, hand-authored agent that has
|
|
|
+ * deliberately not been canonicalised yet. A build that eats files it did not write is worse
|
|
|
+ * than no pruning at all.
|
|
|
+ *
|
|
|
+ * The rule is therefore inverted: the build removes a file only if IT PREVIOUSLY WROTE THAT
|
|
|
+ * FILE. {@link BuildManifest} is the ledger — every write records its path and the sha256 of
|
|
|
+ * the bytes written. Pruning considers only paths the previous manifest claims, and never the
|
|
|
+ * filesystem. A file the build has never generated is not in the ledger, cannot become a
|
|
|
+ * candidate, and is invisible to pruning no matter where it sits. See {@link prunable} for
|
|
|
+ * the four conditions, each of which must hold.
|
|
|
+ */
|
|
|
+
|
|
|
+import { createHash } from "node:crypto";
|
|
|
+import {
|
|
|
+ existsSync,
|
|
|
+ mkdirSync,
|
|
|
+ readFileSync,
|
|
|
+ readdirSync,
|
|
|
+ rmSync,
|
|
|
+ rmdirSync,
|
|
|
+ writeFileSync,
|
|
|
+} from "node:fs";
|
|
|
+import { dirname, join, relative, resolve, sep } from "node:path";
|
|
|
+import { CanonicalAgentLoader, type CanonicalAgentFile } from "./AgentLoader.js";
|
|
|
+import { RegistryEmitter } from "./RegistryEmitter.js";
|
|
|
+import { ClaudeAdapter } from "../adapters/ClaudeAdapter.js";
|
|
|
+import { OpenCodeAdapter } from "../adapters/OpenCodeAdapter.js";
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// TYPES
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/** The targets `oac build` wires up today. A subset of `BuildTargetSchema`'s vocabulary. */
|
|
|
+export const BUILD_TARGETS = ["opencode", "claude-code"] as const;
|
|
|
+
|
|
|
+export type BuildTarget = (typeof BUILD_TARGETS)[number];
|
|
|
+
|
|
|
+/** One file the build produces. */
|
|
|
+export interface BuildFile {
|
|
|
+ /** Repo-relative POSIX path the file lands at when emitted in place. */
|
|
|
+ path: string;
|
|
|
+ /** The exact bytes to write. */
|
|
|
+ content: string;
|
|
|
+ /** The target that produced it; `"registry"` for `registry.json`. */
|
|
|
+ target: BuildTarget | "registry";
|
|
|
+ /** `oac.id` of the source agent, or `undefined` for whole-tree artefacts. */
|
|
|
+ agentId?: string;
|
|
|
+ /** Semantics the target could not carry. Never fatal on their own. */
|
|
|
+ warnings: BuildWarning[];
|
|
|
+}
|
|
|
+
|
|
|
+/** A warning, always carrying the file it came from so it is actionable. */
|
|
|
+export interface BuildWarning {
|
|
|
+ /** Repo-relative path of the SOURCE that caused it, not the emitted file. */
|
|
|
+ source: string;
|
|
|
+ reason: string;
|
|
|
+}
|
|
|
+
|
|
|
+/** Everything a build produced, before anything is written. */
|
|
|
+export interface BuildPlan {
|
|
|
+ /** Emitted files, sorted by path. */
|
|
|
+ files: BuildFile[];
|
|
|
+ /** Every warning across every file, in file order. */
|
|
|
+ warnings: BuildWarning[];
|
|
|
+ /** Canonical agents loaded, sorted by `oac.id`. */
|
|
|
+ agents: CanonicalAgentFile[];
|
|
|
+}
|
|
|
+
|
|
|
+export interface BuildOptions {
|
|
|
+ /** Repository root. Everything resolves against it — no hardcoded paths. */
|
|
|
+ root: string;
|
|
|
+ /** Restrict the build to these targets. Defaults to {@link BUILD_TARGETS} plus the registry. */
|
|
|
+ targets?: readonly BuildTarget[];
|
|
|
+ /** Skip `registry.json`. Defaults to false. */
|
|
|
+ skipRegistry?: boolean;
|
|
|
+ /**
|
|
|
+ * Accepted and ignored: {@link plan} never writes, so a dry run IS a plan. Present because
|
|
|
+ * `dryRun: true` is how callers say what they mean, and because a flag that silently means
|
|
|
+ * nothing is safer than one that silently means something else.
|
|
|
+ */
|
|
|
+ dryRun?: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+/** Where a target's files are written, when not in place. */
|
|
|
+export type OutputRoots = Partial<Record<BuildFile["target"], string>>;
|
|
|
+
|
|
|
+export interface WriteOptions {
|
|
|
+ root: string;
|
|
|
+ /**
|
|
|
+ * Per-target root override, repo-relative. A target listed here is REBASED under the given
|
|
|
+ * directory instead of being written in place — the mechanism `plugins/claude-code/**` is
|
|
|
+ * staged with, so a build can be compared against the shipped tree without touching it.
|
|
|
+ */
|
|
|
+ outputRoots?: OutputRoots;
|
|
|
+ /**
|
|
|
+ * Remove generated files whose source is gone. Only ever considers paths the previous
|
|
|
+ * manifest claims — see {@link prunable}. Defaults to true.
|
|
|
+ */
|
|
|
+ prune?: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+export interface WriteResult {
|
|
|
+ /** Paths written whose bytes changed (or that did not exist). */
|
|
|
+ changed: string[];
|
|
|
+ /** Paths written whose bytes already matched. */
|
|
|
+ unchanged: string[];
|
|
|
+ /** Paths removed as orphans. */
|
|
|
+ removed: string[];
|
|
|
+ /** Orphan candidates left alone, with the reason. */
|
|
|
+ kept: Array<{ path: string; reason: string }>;
|
|
|
+}
|
|
|
+
|
|
|
+/** One line of the ledger: what the build wrote at a path, and where it was allowed to. */
|
|
|
+export interface ManifestEntry {
|
|
|
+ /** sha256 of the bytes the build wrote. */
|
|
|
+ sha256: string;
|
|
|
+ /** The target that produced it. */
|
|
|
+ target: string;
|
|
|
+ /**
|
|
|
+ * The output root this file was written under — `TARGET_ROOTS[target]`, rebased if the
|
|
|
+ * target was staged. Recorded rather than recomputed so pruning can bound itself without
|
|
|
+ * having to be told which staging layout a PREVIOUS build happened to use.
|
|
|
+ */
|
|
|
+ root: string;
|
|
|
+}
|
|
|
+
|
|
|
+/** The build's ledger of what it wrote. Deterministic: sorted keys, no timestamps. */
|
|
|
+export interface BuildManifest {
|
|
|
+ /** Ledger format version, so a future shape change is detectable rather than silent. */
|
|
|
+ version: 1;
|
|
|
+ /** Repo-relative POSIX path -> what the build wrote there. */
|
|
|
+ files: Record<string, ManifestEntry>;
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// PATHS AND CONSTANTS
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+const DEFAULTS = {
|
|
|
+ contentRoot: "content/agents",
|
|
|
+ manifestFile: ".oac/build-manifest.json",
|
|
|
+} as const;
|
|
|
+
|
|
|
+/**
|
|
|
+ * The roots each target is permitted to write, and therefore the only roots pruning may ever
|
|
|
+ * touch. Defence in depth: the manifest is the gate, but a manifest that has been corrupted,
|
|
|
+ * hand-edited or carried over from a different layout must not be able to talk the build into
|
|
|
+ * deleting `src/`. A prune candidate outside its target's root is refused and reported.
|
|
|
+ */
|
|
|
+const TARGET_ROOTS: Readonly<Record<BuildFile["target"], string>> = {
|
|
|
+ opencode: ".opencode/agent",
|
|
|
+ "claude-code": "plugins/claude-code/agents",
|
|
|
+ registry: "registry.json",
|
|
|
+};
|
|
|
+
|
|
|
+/** Locale-independent ordering. `localeCompare` is locale-dependent — never use it here. */
|
|
|
+function compare(a: string, b: string): number {
|
|
|
+ return a < b ? -1 : a > b ? 1 : 0;
|
|
|
+}
|
|
|
+
|
|
|
+/** POSIX-separated, so a plan built on Windows and one built on macOS agree. */
|
|
|
+function toPosix(path: string): string {
|
|
|
+ return path.split(sep).join("/");
|
|
|
+}
|
|
|
+
|
|
|
+/** True when `path` is `root` itself or sits underneath it. Segment-aware, not `startsWith`. */
|
|
|
+function isUnder(path: string, root: string): boolean {
|
|
|
+ return path === root || path.startsWith(`${root}/`);
|
|
|
+}
|
|
|
+
|
|
|
+function sha256(content: string): string {
|
|
|
+ return createHash("sha256").update(content, "utf-8").digest("hex");
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// PLANNING
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/**
|
|
|
+ * Emit one agent for one target, or `null` when the agent does not declare that target.
|
|
|
+ *
|
|
|
+ * `oac.targets` is honoured here and nowhere else: an agent with `targets: ["opencode"]`
|
|
|
+ * produces no Claude Code output because this function returns `null`, not because some later
|
|
|
+ * filter drops it.
|
|
|
+ */
|
|
|
+async function emitAgent(
|
|
|
+ agent: CanonicalAgentFile,
|
|
|
+ target: BuildTarget
|
|
|
+): Promise<BuildFile | null> {
|
|
|
+ if (!agent.oac.targets.includes(target)) return null;
|
|
|
+
|
|
|
+ const source = readFileSync(agent.filePath, "utf-8");
|
|
|
+ const sourcePath = `${DEFAULTS.contentRoot}/${agent.relativePath}`;
|
|
|
+
|
|
|
+ if (target === "opencode") {
|
|
|
+ const adapter = new OpenCodeAdapter();
|
|
|
+ const { content, warnings } = await adapter.fromCanonical(source, { filePath: sourcePath });
|
|
|
+ return {
|
|
|
+ path: adapter.outputPath(agent.relativePath),
|
|
|
+ content,
|
|
|
+ target,
|
|
|
+ agentId: agent.oac.id,
|
|
|
+ warnings: warnings.map((reason) => ({ source: sourcePath, reason })),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const adapter = new ClaudeAdapter();
|
|
|
+ const { path, content, warnings } = await adapter.fromCanonical(source);
|
|
|
+ return {
|
|
|
+ path,
|
|
|
+ content,
|
|
|
+ target,
|
|
|
+ agentId: agent.oac.id,
|
|
|
+ warnings: warnings.map((reason) => ({ source: sourcePath, reason })),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Compute the full build. Reads the canonical tree; writes nothing, ever.
|
|
|
+ *
|
|
|
+ * A failure anywhere — a schema violation, an unrepresentable permission block an adapter
|
|
|
+ * refuses to widen — rejects. Fail-closed is the whole point: a capability that cannot be
|
|
|
+ * expressed on a target is an error, never a silent grant.
|
|
|
+ */
|
|
|
+export async function plan(options: BuildOptions): Promise<BuildPlan> {
|
|
|
+ const root = resolve(options.root);
|
|
|
+ const targets = options.targets ?? BUILD_TARGETS;
|
|
|
+ const agents = await new CanonicalAgentLoader(join(root, DEFAULTS.contentRoot))
|
|
|
+ .loadFromDirectory();
|
|
|
+
|
|
|
+ const files: BuildFile[] = [];
|
|
|
+
|
|
|
+ // Agents outer, targets inner, both in a content-determined order. Iterating `oac.targets`
|
|
|
+ // instead would make output order depend on the order an author happened to list them in.
|
|
|
+ for (const agent of agents) {
|
|
|
+ for (const target of targets) {
|
|
|
+ const file = await emitAgent(agent, target);
|
|
|
+ if (file !== null) files.push(file);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (options.skipRegistry !== true) {
|
|
|
+ files.push({
|
|
|
+ path: "registry.json",
|
|
|
+ content: await new RegistryEmitter(root).emitJson(),
|
|
|
+ target: "registry",
|
|
|
+ warnings: [],
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ files.sort((a, b) => compare(a.path, b.path));
|
|
|
+
|
|
|
+ return { files, agents, warnings: files.flatMap((file) => file.warnings) };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * The build as a plain path -> bytes map.
|
|
|
+ *
|
|
|
+ * The entry point `tests/unit/build/determinism.test.ts` drives: two calls over one tree must
|
|
|
+ * produce identical maps.
|
|
|
+ */
|
|
|
+export async function build(options: BuildOptions): Promise<Map<string, string>> {
|
|
|
+ const { files } = await plan(options);
|
|
|
+ return new Map(files.map((file) => [file.path, file.content]));
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Emit one agent, named by `oac.id`, for one target.
|
|
|
+ *
|
|
|
+ * Identity is `oac.id` and never a filename: `content/agents/subagents/code/test-engineer.md`
|
|
|
+ * declares `id: tester`, and `tester` is what `registry.json`, the profiles and the context
|
|
|
+ * docs all reference. Resolving by path here would mint an id nothing refers to.
|
|
|
+ *
|
|
|
+ * @throws when no agent declares `id`, or when that agent does not declare `target`.
|
|
|
+ */
|
|
|
+export async function buildAgent(id: string, target: BuildTarget): Promise<string> {
|
|
|
+ return buildAgentIn(process.cwd(), id, target);
|
|
|
+}
|
|
|
+
|
|
|
+/** {@link buildAgent} against an explicit root — the testable form. */
|
|
|
+export async function buildAgentIn(
|
|
|
+ root: string,
|
|
|
+ id: string,
|
|
|
+ target: BuildTarget
|
|
|
+): Promise<string> {
|
|
|
+ const agents = await new CanonicalAgentLoader(join(resolve(root), DEFAULTS.contentRoot))
|
|
|
+ .loadFromDirectory();
|
|
|
+ const agent = agents.find((candidate) => candidate.oac.id === id);
|
|
|
+
|
|
|
+ if (agent === undefined) {
|
|
|
+ throw new Error(
|
|
|
+ `No canonical agent declares oac.id "${id}". Known ids: ` +
|
|
|
+ `${agents.map((candidate) => candidate.oac.id).join(", ")}`
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const file = await emitAgent(agent, target);
|
|
|
+ if (file === null) {
|
|
|
+ throw new Error(
|
|
|
+ `Agent "${id}" does not declare target "${target}" (declares: ` +
|
|
|
+ `${agent.oac.targets.join(", ")}), so it emits nothing there.`
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return file.content;
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// MANIFEST
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/** Read the previous manifest, or an empty one. An absent ledger prunes nothing — safe. */
|
|
|
+export function readManifest(root: string): BuildManifest {
|
|
|
+ const path = join(resolve(root), DEFAULTS.manifestFile);
|
|
|
+ if (!existsSync(path)) return { version: 1, files: {} };
|
|
|
+
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as BuildManifest;
|
|
|
+ // A ledger we cannot vouch for prunes nothing rather than pruning wrongly.
|
|
|
+ if (parsed.version !== 1 || typeof parsed.files !== "object" || parsed.files === null) {
|
|
|
+ return { version: 1, files: {} };
|
|
|
+ }
|
|
|
+ return parsed;
|
|
|
+ } catch {
|
|
|
+ return { version: 1, files: {} };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** Serialise a manifest with sorted keys and a trailing newline. No clock, by construction. */
|
|
|
+export function serializeManifest(manifest: BuildManifest): string {
|
|
|
+ const files: BuildManifest["files"] = {};
|
|
|
+ for (const path of Object.keys(manifest.files).sort(compare)) {
|
|
|
+ files[path] = manifest.files[path]!;
|
|
|
+ }
|
|
|
+ return `${JSON.stringify({ version: manifest.version, files }, null, 2)}\n`;
|
|
|
+}
|
|
|
+
|
|
|
+/** The manifest a plan implies, given where each target is actually written. */
|
|
|
+function manifestFor(files: readonly BuildFile[], outputRoots: OutputRoots): BuildManifest {
|
|
|
+ const entries: BuildManifest["files"] = {};
|
|
|
+ for (const file of files) {
|
|
|
+ entries[rebase(file.path, file.target, outputRoots)] = {
|
|
|
+ sha256: sha256(file.content),
|
|
|
+ target: file.target,
|
|
|
+ root: rebase(TARGET_ROOTS[file.target], file.target, outputRoots),
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return { version: 1, files: entries };
|
|
|
+}
|
|
|
+
|
|
|
+/** Where a file actually lands, honouring a staging override for its target. */
|
|
|
+function rebase(path: string, target: BuildFile["target"], outputRoots: OutputRoots): string {
|
|
|
+ const override = outputRoots[target];
|
|
|
+ return override === undefined ? path : toPosix(join(override, path));
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// PRUNING
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/**
|
|
|
+ * Decide whether an orphan candidate may be deleted.
|
|
|
+ *
|
|
|
+ * Called only for paths the PREVIOUS manifest claims — that gate happens in {@link write} and
|
|
|
+ * is the load-bearing one. Everything here is a second line of defence, because the cost of a
|
|
|
+ * wrong answer is an unrecoverable deletion of someone's work:
|
|
|
+ *
|
|
|
+ * 1. **The manifest claims it.** (Enforced by the caller.) The build wrote this exact path
|
|
|
+ * on a previous run. `.opencode/agent/eval-runner.md` has no `content/` source, was never
|
|
|
+ * emitted, is not in the ledger, and therefore never reaches this function at all.
|
|
|
+ * 2. **The current build does not produce it.** Otherwise it is not an orphan, it is output.
|
|
|
+ * 3. **It sits under an output root its own target could legitimately have written.** The
|
|
|
+ * manifest records that root, but the record is VERIFIED rather than trusted: it must be
|
|
|
+ * the target's canonical root, or that root rebased under a staging directory. A ledger
|
|
|
+ * that has been corrupted, hand-edited, or carried over from another layout therefore
|
|
|
+ * cannot talk the build into deleting `src/`.
|
|
|
+ * 4. **Its bytes still match what the build wrote.** If a human edited a generated file, the
|
|
|
+ * hash diverges and we refuse: their edit is misplaced, but it is theirs, and reporting it
|
|
|
+ * is strictly better than destroying it.
|
|
|
+ *
|
|
|
+ * @returns `null` when the file may be removed, or the reason it is being kept.
|
|
|
+ */
|
|
|
+function prunable(absolute: string, path: string, entry: ManifestEntry): string | null {
|
|
|
+ const canonical = TARGET_ROOTS[entry.target as BuildFile["target"]];
|
|
|
+
|
|
|
+ if (canonical === undefined) {
|
|
|
+ return `manifest names an unknown target "${entry.target}"`;
|
|
|
+ }
|
|
|
+ // The recorded root is either the canonical one or the canonical one under a staging dir.
|
|
|
+ // Anything else means the ledger is not describing a tree this build owns.
|
|
|
+ if (entry.root !== canonical && !entry.root.endsWith(`/${canonical}`)) {
|
|
|
+ return `manifest records root "${entry.root}", which is not the ${entry.target} output root`;
|
|
|
+ }
|
|
|
+ if (!isUnder(path, entry.root)) {
|
|
|
+ return `manifest entry sits outside its recorded output root (${entry.root})`;
|
|
|
+ }
|
|
|
+ if (!existsSync(absolute)) {
|
|
|
+ return "already gone";
|
|
|
+ }
|
|
|
+ if (sha256(readFileSync(absolute, "utf-8")) !== entry.sha256) {
|
|
|
+ return "modified since it was generated — refusing to delete a file someone has edited";
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Remove a file and every directory it leaves empty, stopping at its own output root.
|
|
|
+ *
|
|
|
+ * The root itself is never removed: an empty `.opencode/agent/` is a legitimate state, and
|
|
|
+ * deleting the directory a target is defined by would be a surprise well beyond "prune".
|
|
|
+ *
|
|
|
+ * `rmdirSync`, never `rmSync`: `rmSync` without `recursive` refuses a directory outright, and
|
|
|
+ * WITH `recursive` it would delete a non-empty tree — the emptiness check above it is the only
|
|
|
+ * thing standing between "tidy up" and "remove the subtree". `rmdirSync` fails closed on a
|
|
|
+ * non-empty directory, so the guard is enforced by the syscall rather than only by us.
|
|
|
+ *
|
|
|
+ * Directory tidying is best-effort: the file removal has already succeeded, which is the part
|
|
|
+ * that matters. A concurrent write that refills the directory must not turn a correct build
|
|
|
+ * into a failed one.
|
|
|
+ */
|
|
|
+function removeAndPruneDirs(root: string, path: string, outputRoot: string): void {
|
|
|
+ rmSync(join(root, path));
|
|
|
+
|
|
|
+ const stopAt = join(root, outputRoot);
|
|
|
+ let dir = dirname(join(root, path));
|
|
|
+
|
|
|
+ while (dir !== stopAt && isUnder(toPosix(dir), toPosix(stopAt))) {
|
|
|
+ try {
|
|
|
+ if (readdirSync(dir).length > 0) return;
|
|
|
+ rmdirSync(dir);
|
|
|
+ } catch {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ dir = dirname(dir);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// WRITING
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/**
|
|
|
+ * Reconcile the disk to a plan: write every file, prune the orphans, record the ledger.
|
|
|
+ *
|
|
|
+ * Writes are content-conditional — a file whose bytes already match is not rewritten, so a
|
|
|
+ * no-op build does not churn mtimes and `--check` has something meaningful to report.
|
|
|
+ */
|
|
|
+export function write(plan: BuildPlan, options: WriteOptions): WriteResult {
|
|
|
+ const root = resolve(options.root);
|
|
|
+ const outputRoots = options.outputRoots ?? {};
|
|
|
+ const result: WriteResult = { changed: [], unchanged: [], removed: [], kept: [] };
|
|
|
+
|
|
|
+ for (const file of plan.files) {
|
|
|
+ const path = rebase(file.path, file.target, outputRoots);
|
|
|
+ const absolute = join(root, path);
|
|
|
+ const exists = existsSync(absolute);
|
|
|
+
|
|
|
+ if (exists && readFileSync(absolute, "utf-8") === file.content) {
|
|
|
+ result.unchanged.push(path);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ mkdirSync(dirname(absolute), { recursive: true });
|
|
|
+ writeFileSync(absolute, file.content, "utf-8");
|
|
|
+ result.changed.push(path);
|
|
|
+ }
|
|
|
+
|
|
|
+ const next = manifestFor(plan.files, outputRoots);
|
|
|
+
|
|
|
+ if (options.prune !== false) {
|
|
|
+ const previous = readManifest(root);
|
|
|
+ // THE gate: candidates come from the ledger, never from a directory scan. A file this
|
|
|
+ // build has not written and no previous build wrote is not enumerable here.
|
|
|
+ for (const path of Object.keys(previous.files).sort(compare)) {
|
|
|
+ if (path in next.files) continue;
|
|
|
+
|
|
|
+ const entry = previous.files[path]!;
|
|
|
+ const reason = prunable(join(root, path), path, entry);
|
|
|
+
|
|
|
+ if (reason === null) {
|
|
|
+ removeAndPruneDirs(root, path, entry.root);
|
|
|
+ result.removed.push(path);
|
|
|
+ } else if (reason !== "already gone") {
|
|
|
+ result.kept.push({ path, reason });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const manifestPath = join(root, DEFAULTS.manifestFile);
|
|
|
+ mkdirSync(dirname(manifestPath), { recursive: true });
|
|
|
+ writeFileSync(manifestPath, serializeManifest(next), "utf-8");
|
|
|
+
|
|
|
+ result.changed.sort(compare);
|
|
|
+ result.unchanged.sort(compare);
|
|
|
+
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+// ============================================================================
|
|
|
+// CHECKING
|
|
|
+// ============================================================================
|
|
|
+
|
|
|
+/** One file whose on-disk bytes disagree with the plan. */
|
|
|
+export interface Drift {
|
|
|
+ path: string;
|
|
|
+ status: "missing" | "modified" | "orphan";
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Compare a plan against the disk without touching it — the engine behind `--check`.
|
|
|
+ *
|
|
|
+ * Orphans are reported from the manifest for the same reason pruning takes them from there:
|
|
|
+ * a directory scan would report `eval-runner.md` as drift on every run.
|
|
|
+ */
|
|
|
+export function check(plan: BuildPlan, options: WriteOptions): Drift[] {
|
|
|
+ const root = resolve(options.root);
|
|
|
+ const outputRoots = options.outputRoots ?? {};
|
|
|
+ const drift: Drift[] = [];
|
|
|
+ const next = manifestFor(plan.files, outputRoots);
|
|
|
+
|
|
|
+ for (const file of plan.files) {
|
|
|
+ const path = rebase(file.path, file.target, outputRoots);
|
|
|
+ const absolute = join(root, path);
|
|
|
+
|
|
|
+ if (!existsSync(absolute)) drift.push({ path, status: "missing" });
|
|
|
+ else if (readFileSync(absolute, "utf-8") !== file.content) {
|
|
|
+ drift.push({ path, status: "modified" });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const path of Object.keys(readManifest(root).files)) {
|
|
|
+ if (!(path in next.files) && existsSync(join(root, path))) {
|
|
|
+ drift.push({ path, status: "orphan" });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return drift.sort((a, b) => compare(a.path, b.path));
|
|
|
+}
|
|
|
+
|
|
|
+/** Repo-relative path of a file, POSIX-separated. Exported for the CLI's reporting. */
|
|
|
+export function repoRelative(root: string, absolute: string): string {
|
|
|
+ return toPosix(relative(resolve(root), absolute));
|
|
|
+}
|