determinism.test.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /**
  2. * Build determinism — building twice over the same input yields byte-identical output.
  3. *
  4. * This is not a nicety. The whole refactor rests on `oac build && git diff --exit-code`
  5. * returning 0 (task.json exit criteria): generated trees stay COMMITTED, and CI gates drift
  6. * by rebuilding and diffing. A build with any nondeterminism — a timestamp, an unsorted
  7. * directory read, a `JSON.stringify` over an object whose key order depends on insertion —
  8. * turns that gate into a coin flip and it gets disabled within a week.
  9. *
  10. * The failure mode is specifically NOT caught by "does the output look right" tests: a build
  11. * that emits a timestamp is perfectly correct on every single run and still fails the gate.
  12. *
  13. * Determinism rules being asserted here (07 Stage 3 / 04 §2.1): stable input sort, no
  14. * timestamps in content, fixed key order.
  15. */
  16. import { describe, it, expect } from "vitest";
  17. import { readFileSync } from "node:fs";
  18. import {
  19. importPendingSymbols,
  20. packagePath,
  21. repoPath,
  22. requireMethod,
  23. } from "../../support/pending.js";
  24. const FIXTURE = packagePath("tests/golden/fixtures/fixture-reviewer.md");
  25. /** Anything that would make two runs differ. Matched against emitted content, not source. */
  26. const NONDETERMINISM = [
  27. { name: "an ISO timestamp", pattern: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ },
  28. { name: "a 'generated at' stamp", pattern: /generated (at|on)[:\s]/i },
  29. { name: "an absolute home path", pattern: /\/(Users|home)\/[^/\s"]+/ },
  30. ];
  31. const ADAPTERS = [
  32. { target: "opencode", path: "src/adapters/OpenCodeAdapter.ts", symbol: "OpenCodeAdapter", owedBy: "subtask 06" },
  33. { target: "claude-code", path: "src/adapters/ClaudeAdapter.ts", symbol: "ClaudeAdapter", owedBy: "subtask 07" },
  34. ] as const;
  35. interface Adapter {
  36. fromCanonical(source: string): Promise<{ content: string }>;
  37. }
  38. async function adapterFor(entry: (typeof ADAPTERS)[number], why: string): Promise<Adapter> {
  39. const owedBy = `${entry.owedBy} (${entry.path})`;
  40. const module = await importPendingSymbols<Record<string, new () => Adapter>>(
  41. entry.path,
  42. [entry.symbol],
  43. owedBy,
  44. why
  45. );
  46. // ClaudeAdapter exists already but speaks `fromOAC`; without this the test dies as a bare
  47. // TypeError rather than naming the interface it is waiting on.
  48. return requireMethod(new module[entry.symbol]!(), "fromCanonical", owedBy, why);
  49. }
  50. // ============================================================================
  51. // RED — adapter-level determinism (subtasks 06/07)
  52. // ============================================================================
  53. describe.each(ADAPTERS)("$target adapter determinism", (entry) => {
  54. it("emits byte-identical output when invoked twice on the same input", async () => {
  55. const adapter = await adapterFor(
  56. entry,
  57. "two runs over identical input produce identical bytes, so `oac build && git diff " +
  58. "--exit-code` is a real gate rather than a coin flip"
  59. );
  60. const source = readFileSync(FIXTURE, "utf-8");
  61. const first = await adapter.fromCanonical(source);
  62. const second = await adapter.fromCanonical(source);
  63. expect(second.content).toBe(first.content);
  64. });
  65. it("emits nothing that varies between runs", async () => {
  66. const adapter = await adapterFor(
  67. entry,
  68. "emitted content carries no timestamp, absolute path or other per-run value"
  69. );
  70. const { content } = await adapter.fromCanonical(readFileSync(FIXTURE, "utf-8"));
  71. for (const { name, pattern } of NONDETERMINISM) {
  72. expect(content, `emitted content contains ${name}`).not.toMatch(pattern);
  73. }
  74. });
  75. it("is insensitive to the order the same input is presented in", async () => {
  76. // Two separately-constructed adapters over the same source must agree. If any per-
  77. // instance state (a cache, a counter, a Set iteration) leaks into output, this catches
  78. // it where a single instance called twice would not.
  79. const source = readFileSync(FIXTURE, "utf-8");
  80. const a = await adapterFor(entry, "adapter output depends only on its input");
  81. const b = await adapterFor(entry, "adapter output depends only on its input");
  82. expect((await b.fromCanonical(source)).content).toBe((await a.fromCanonical(source)).content);
  83. });
  84. });
  85. // ============================================================================
  86. // RED — whole-build determinism (subtask 10) and the registry emitter (subtask 08)
  87. // ============================================================================
  88. describe("full build determinism", () => {
  89. it("produces byte-identical trees across two runs", async () => {
  90. const { build } = await importPendingSymbols<{
  91. build: (options: { root: string; dryRun: true }) => Promise<Map<string, string>>;
  92. }>(
  93. "src/core/BuildPipeline.ts",
  94. ["build"],
  95. "subtask 10 (oac build) via src/core/BuildPipeline.ts",
  96. "a whole build run twice yields byte-identical output for every emitted file — the " +
  97. "property `oac build && git diff --exit-code` depends on"
  98. );
  99. const first = await build({ root: repoPath(), dryRun: true });
  100. const second = await build({ root: repoPath(), dryRun: true });
  101. expect([...second.keys()].sort()).toEqual([...first.keys()].sort());
  102. for (const [path, content] of first) {
  103. expect(second.get(path), `${path} differs between two builds of the same tree`).toBe(content);
  104. }
  105. });
  106. it("emits registry.json with stable ordering", async () => {
  107. const { emitRegistry } = await importPendingSymbols<{
  108. emitRegistry: (root: string) => Promise<string>;
  109. }>(
  110. "src/core/RegistryEmitter.ts",
  111. ["emitRegistry"],
  112. "subtask 08 (src/core/RegistryEmitter.ts)",
  113. "registry.json is emitted with stable key and array ordering, so a rebuild that " +
  114. "changes nothing produces no diff"
  115. );
  116. const first = await emitRegistry(repoPath());
  117. const second = await emitRegistry(repoPath());
  118. expect(second).toBe(first);
  119. for (const { name, pattern } of NONDETERMINISM) {
  120. expect(first, `registry.json contains ${name}`).not.toMatch(pattern);
  121. }
  122. });
  123. it("reproduces the committed registry.json exactly", async () => {
  124. // The generated tree is committed, so a rebuild on a clean checkout must be a no-op.
  125. const { emitRegistry } = await importPendingSymbols<{
  126. emitRegistry: (root: string) => Promise<string>;
  127. }>(
  128. "src/core/RegistryEmitter.ts",
  129. ["emitRegistry"],
  130. "subtask 08 (src/core/RegistryEmitter.ts)",
  131. "rebuilding registry.json reproduces the committed file byte-for-byte"
  132. );
  133. expect(await emitRegistry(repoPath())).toBe(readFileSync(repoPath("registry.json"), "utf-8"));
  134. });
  135. });