build-pipeline.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. /**
  2. * BuildPipeline — targets, determinism, and the orphan-removal safety envelope.
  3. *
  4. * ─── Why the pruning tests are the important half of this file ───────────────────────────
  5. *
  6. * Orphan removal is the one part of `oac build` that DELETES. Everything else, if wrong,
  7. * produces a bad file someone notices in review; this, if wrong, destroys work that was never
  8. * committed and is not recoverable.
  9. *
  10. * The live case is not hypothetical. `.opencode/agent/eval-runner.md` is a real, shipped,
  11. * hand-authored agent with no `content/agents/` counterpart — deliberately, until it is
  12. * canonicalised. The obvious pruning rule ("delete anything under `.opencode/agent/` with no
  13. * canonical source") deletes it. So the rule is inverted: the build removes only what a
  14. * PREVIOUS build recorded writing. These tests pin that inversion from both sides — that a
  15. * genuine orphan does go, and that an unclaimed file does not — because a pruner that only
  16. * ever gets tested on the happy path is a pruner that eats someone's afternoon exactly once.
  17. */
  18. import { describe, it, expect, beforeEach, afterEach } from "vitest";
  19. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
  20. import { dirname, join } from "node:path";
  21. import { tmpdir } from "node:os";
  22. import {
  23. build,
  24. buildAgentIn,
  25. check,
  26. plan,
  27. readManifest,
  28. serializeManifest,
  29. write,
  30. } from "../../../src/core/BuildPipeline.js";
  31. import { repoPath } from "../../support/pending.js";
  32. const REPO = repoPath();
  33. // ============================================================================
  34. // A throwaway tree, so nothing here can touch the real repo
  35. // ============================================================================
  36. let root: string;
  37. /** A minimal canonical agent. `targets` is the knob most of these tests turn. */
  38. /**
  39. * A canonical fixture agent.
  40. *
  41. * A claude-code target automatically gets an authored `tools` override, because every agent
  42. * targeting Claude Code must have one — the adapter refuses to derive it (Claude Code cannot
  43. * enforce a per-agent scope, so there is no honest derivation). An agent without one is
  44. * incomplete, so it cannot be this helper's default shape; tests that want that case strip it.
  45. */
  46. function canonicalAgent(id: string, targets: readonly string[]): string {
  47. const overrides = targets.includes("claude-code")
  48. ? [" overrides:", " claude-code:", " tools: [Read]"]
  49. : [];
  50. return [
  51. "---",
  52. `name: ${id}`,
  53. `description: Fixture agent ${id}.`,
  54. "mode: subagent",
  55. "permission:",
  56. " read:",
  57. ' "*": "allow"',
  58. "oac:",
  59. ` id: ${id}`,
  60. ` name: ${id}`,
  61. " category: subagents/test",
  62. " type: subagent",
  63. ' version: "1.0.0"',
  64. " author: opencode",
  65. " targets:",
  66. ...targets.map((target) => ` - ${target}`),
  67. ...overrides,
  68. "---",
  69. "",
  70. `# ${id}`,
  71. "",
  72. "Body.",
  73. "",
  74. ].join("\n");
  75. }
  76. function put(relativePath: string, content: string): void {
  77. const absolute = join(root, relativePath);
  78. mkdirSync(dirname(absolute), { recursive: true });
  79. writeFileSync(absolute, content, "utf-8");
  80. }
  81. /** A registry the emitter can carry non-agent data through from. */
  82. const BASE_REGISTRY = {
  83. version: "1.0.0",
  84. metadata: { lastUpdated: "2026-01-01" },
  85. components: { agents: [], subagents: [], contexts: [] },
  86. profiles: {},
  87. };
  88. beforeEach(() => {
  89. root = mkdtempSync(join(tmpdir(), "oac-build-"));
  90. put("registry.json", `${JSON.stringify(BASE_REGISTRY, null, 2)}\n`);
  91. put("content/agents/subagents/test/alpha.md", canonicalAgent("alpha", ["opencode"]));
  92. put("content/agents/subagents/test/beta.md", canonicalAgent("beta", ["opencode", "claude-code"]));
  93. });
  94. afterEach(() => {
  95. rmSync(root, { recursive: true, force: true });
  96. });
  97. // ============================================================================
  98. // targets:
  99. // ============================================================================
  100. describe("oac.targets", () => {
  101. it("emits an agent only to the targets it declares", async () => {
  102. const files = await build({ root });
  103. expect(files.has(".opencode/agent/subagents/test/alpha.md")).toBe(true);
  104. expect(files.has(".opencode/agent/subagents/test/beta.md")).toBe(true);
  105. expect(files.has("plugins/claude-code/agents/beta.md")).toBe(true);
  106. // alpha declares targets: [opencode] only — it must produce NO claude-code output.
  107. expect(files.has("plugins/claude-code/agents/alpha.md")).toBe(false);
  108. });
  109. it("restricting --target narrows the build without changing the bytes", async () => {
  110. const all = await build({ root });
  111. const only = await build({ root, targets: ["opencode"], skipRegistry: true });
  112. expect([...only.keys()]).toEqual(
  113. [...all.keys()].filter((path) => path.startsWith(".opencode/")),
  114. );
  115. for (const [path, content] of only) expect(content).toBe(all.get(path));
  116. });
  117. it("strips the oac: block from OpenCode output but keeps the body", async () => {
  118. const files = await build({ root, targets: ["opencode"], skipRegistry: true });
  119. const emitted = files.get(".opencode/agent/subagents/test/alpha.md") ?? "";
  120. expect(emitted).not.toContain("oac:");
  121. expect(emitted).toContain("name: alpha");
  122. expect(emitted).toContain("Body.");
  123. });
  124. });
  125. // ============================================================================
  126. // buildAgent
  127. // ============================================================================
  128. describe("buildAgent", () => {
  129. it("emits one agent by its oac.id", async () => {
  130. expect(await buildAgentIn(root, "beta", "claude-code")).toContain("name: beta");
  131. });
  132. it("names the known ids when asked for one that does not exist", async () => {
  133. await expect(buildAgentIn(root, "nope", "opencode")).rejects.toThrow(/alpha, beta/);
  134. });
  135. it("refuses a target the agent does not declare, rather than inventing output", async () => {
  136. await expect(buildAgentIn(root, "alpha", "claude-code")).rejects.toThrow(
  137. /does not declare target "claude-code"/,
  138. );
  139. });
  140. });
  141. // ============================================================================
  142. // Determinism
  143. // ============================================================================
  144. describe("determinism", () => {
  145. it("is a fixed point: building over its own output changes nothing", async () => {
  146. const first = write(await plan({ root }), { root });
  147. expect(first.changed.length).toBeGreaterThan(0);
  148. const second = write(await plan({ root }), { root });
  149. expect(second.changed, "a second build rewrote files").toEqual([]);
  150. expect(check(await plan({ root }), { root })).toEqual([]);
  151. });
  152. it("writes a manifest with sorted keys and no timestamp", async () => {
  153. write(await plan({ root }), { root });
  154. const manifest = readFileSync(join(root, ".oac/build-manifest.json"), "utf-8");
  155. expect(manifest).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
  156. expect(Object.keys(readManifest(root).files)).toEqual(
  157. [...Object.keys(readManifest(root).files)].sort(),
  158. );
  159. expect(serializeManifest(readManifest(root))).toBe(manifest);
  160. });
  161. });
  162. // ============================================================================
  163. // Staging
  164. // ============================================================================
  165. describe("staged targets", () => {
  166. it("rebases a staged target and leaves the in-place tree alone", async () => {
  167. write(await plan({ root }), { root, outputRoots: { "claude-code": ".tmp/stage" } });
  168. expect(existsSync(join(root, ".tmp/stage/plugins/claude-code/agents/beta.md"))).toBe(true);
  169. expect(existsSync(join(root, "plugins/claude-code/agents/beta.md"))).toBe(false);
  170. });
  171. });
  172. // ============================================================================
  173. // Orphan removal — the part that deletes
  174. // ============================================================================
  175. describe("orphan removal", () => {
  176. const ALPHA_OUT = ".opencode/agent/subagents/test/alpha.md";
  177. it("removes generated output when its canonical source is deleted", async () => {
  178. write(await plan({ root }), { root });
  179. expect(existsSync(join(root, ALPHA_OUT))).toBe(true);
  180. rmSync(join(root, "content/agents/subagents/test/alpha.md"));
  181. const result = write(await plan({ root }), { root });
  182. expect(result.removed).toContain(ALPHA_OUT);
  183. expect(existsSync(join(root, ALPHA_OUT))).toBe(false);
  184. });
  185. it("NEVER removes a file it did not generate, however orphan-shaped it looks", async () => {
  186. // This is `.opencode/agent/eval-runner.md`: a real agent, in the output tree, with no
  187. // canonical source, deliberately not canonicalised yet. It is not in the ledger, so it is
  188. // not enumerable as a candidate — no rule, guard or heuristic ever gets a vote.
  189. put(".opencode/agent/eval-runner.md", "---\nname: eval-runner\n---\n\nUncommitted work.\n");
  190. write(await plan({ root }), { root });
  191. write(await plan({ root }), { root });
  192. expect(existsSync(join(root, ".opencode/agent/eval-runner.md"))).toBe(true);
  193. expect(readFileSync(join(root, ".opencode/agent/eval-runner.md"), "utf-8")).toContain(
  194. "Uncommitted work.",
  195. );
  196. });
  197. it("removes the directory an orphan leaves empty, but never the target's own root", async () => {
  198. // Regression: this used to throw EFAULT — `rmSync` without `recursive` refuses to remove a
  199. // directory at all, so the tidy-up aborted the whole build AFTER it had already deleted
  200. // the file. Caught by running the real command, not by any test that existed at the time.
  201. write(await plan({ root }), { root });
  202. rmSync(join(root, "content/agents/subagents/test/alpha.md"));
  203. rmSync(join(root, "content/agents/subagents/test/beta.md"));
  204. const result = write(await plan({ root }), { root });
  205. expect(result.removed).toContain(ALPHA_OUT);
  206. expect(existsSync(join(root, ".opencode/agent/subagents/test"))).toBe(false);
  207. expect(existsSync(join(root, ".opencode/agent")), "the output root itself").toBe(true);
  208. });
  209. it("leaves a directory alone while it still holds a file the build does not own", async () => {
  210. put(".opencode/agent/subagents/test/notes.md", "hand-written, not generated\n");
  211. write(await plan({ root }), { root });
  212. rmSync(join(root, "content/agents/subagents/test/alpha.md"));
  213. rmSync(join(root, "content/agents/subagents/test/beta.md"));
  214. write(await plan({ root }), { root });
  215. expect(existsSync(join(root, ".opencode/agent/subagents/test/notes.md"))).toBe(true);
  216. });
  217. it("prunes nothing on a first build, when there is no ledger to prune from", async () => {
  218. put(".opencode/agent/stranger.md", "not ours\n");
  219. const result = write(await plan({ root }), { root });
  220. expect(result.removed).toEqual([]);
  221. expect(existsSync(join(root, ".opencode/agent/stranger.md"))).toBe(true);
  222. });
  223. it("keeps — and reports — an orphan a human has edited since it was generated", async () => {
  224. write(await plan({ root }), { root });
  225. rmSync(join(root, "content/agents/subagents/test/alpha.md"));
  226. writeFileSync(join(root, ALPHA_OUT), "hand-edited, and not by the build\n", "utf-8");
  227. const result = write(await plan({ root }), { root });
  228. expect(result.removed).not.toContain(ALPHA_OUT);
  229. expect(result.kept.map((entry) => entry.path)).toContain(ALPHA_OUT);
  230. expect(result.kept[0]?.reason).toMatch(/modified since it was generated/);
  231. expect(existsSync(join(root, ALPHA_OUT))).toBe(true);
  232. });
  233. it("refuses a manifest that points a delete outside its target's output root", async () => {
  234. write(await plan({ root }), { root });
  235. put("src/precious.ts", "export const x = 1;\n");
  236. // A corrupted / hand-edited ledger claiming the build wrote into src/.
  237. const manifest = readManifest(root);
  238. manifest.files["src/precious.ts"] = {
  239. sha256: "0".repeat(64),
  240. target: "opencode",
  241. root: "src",
  242. };
  243. put(".oac/build-manifest.json", serializeManifest(manifest));
  244. const result = write(await plan({ root }), { root });
  245. expect(result.removed).not.toContain("src/precious.ts");
  246. expect(existsSync(join(root, "src/precious.ts"))).toBe(true);
  247. });
  248. it("--no-prune leaves orphans in place", async () => {
  249. write(await plan({ root }), { root });
  250. rmSync(join(root, "content/agents/subagents/test/alpha.md"));
  251. const result = write(await plan({ root }), { root, prune: false });
  252. expect(result.removed).toEqual([]);
  253. expect(existsSync(join(root, ALPHA_OUT))).toBe(true);
  254. });
  255. it("reports an orphan as drift under check() without removing it", async () => {
  256. write(await plan({ root }), { root });
  257. rmSync(join(root, "content/agents/subagents/test/alpha.md"));
  258. const drift = check(await plan({ root }), { root });
  259. expect(drift).toContainEqual({ path: ALPHA_OUT, status: "orphan" });
  260. expect(existsSync(join(root, ALPHA_OUT))).toBe(true);
  261. });
  262. });
  263. // ============================================================================
  264. // Fail-closed
  265. // ============================================================================
  266. describe("failure handling", () => {
  267. it("rejects the whole build on a schema violation rather than emitting a partial tree", async () => {
  268. put("content/agents/subagents/test/broken.md", "---\nname: broken\n---\n\nNo oac block.\n");
  269. await expect(plan({ root })).rejects.toThrow();
  270. });
  271. it("fails the build when an agent targets claude-code without authoring its tools", async () => {
  272. // Previously this asserted a warning, then a refusal-on-scoped-rules. Both were downstream
  273. // of deriving the tool list from `permission:`, which is not possible — Claude Code cannot
  274. // enforce a per-agent scope, so every derived answer is either a crippled agent or a silent
  275. // widening. The list is authored; an agent that omits it is incomplete, and the build says
  276. // so rather than picking a default.
  277. put(
  278. "content/agents/subagents/test/gamma.md",
  279. canonicalAgent("gamma", ["claude-code"]).replace(
  280. " overrides:\n claude-code:\n tools: [Read]\n",
  281. "",
  282. ),
  283. );
  284. await expect(plan({ root, targets: ["claude-code"], skipRegistry: true })).rejects.toThrow(
  285. /declares no oac\.overrides\.claude-code\.tools/,
  286. );
  287. });
  288. it("names the source file in a refusal, not just the agent id", async () => {
  289. // The adapter only knows `gamma`. Whoever has to make the decision needs the path.
  290. put(
  291. "content/agents/subagents/test/gamma.md",
  292. canonicalAgent("gamma", ["claude-code"]).replace(
  293. " overrides:\n claude-code:\n tools: [Read]\n",
  294. "",
  295. ),
  296. );
  297. await expect(plan({ root, targets: ["claude-code"], skipRegistry: true })).rejects.toThrow(
  298. /content\/agents\/subagents\/test\/gamma\.md:/,
  299. );
  300. });
  301. it("attaches the source path to every warning, so a warning is actionable", async () => {
  302. const built = await plan({ root: REPO });
  303. for (const warning of built.warnings) {
  304. expect(warning.source, warning.reason).toMatch(/^content\/agents\/.+\.md$/);
  305. }
  306. });
  307. });