From dc97f7a09effc1159cc3bcdc92801723a2b78112 Mon Sep 17 00:00:00 2001 From: Andres J Date: Thu, 13 Aug 2026 11:29:51 -0600 Subject: [PATCH 1/3] feat(app-builder): prove installed CLI generation --- packages/app-builder/cli/package.json | 3 + packages/app-builder/cli/src/main.ts | 8 + packages/app-builder/e2e/project.json | 6 + .../app-builder/e2e/src/golden-workspace.ts | 314 +++++++----------- .../app-builder/e2e/tests/golden.e2e.test.ts | 56 ++-- packages/app-builder/e2e/vitest.config.mts | 7 - .../blueprint/workspace/nx.json.template | 2 + .../generation/tests/fixtures/todo-v1.ts | 2 +- 8 files changed, 167 insertions(+), 231 deletions(-) diff --git a/packages/app-builder/cli/package.json b/packages/app-builder/cli/package.json index 38d63e89..e2c616e4 100644 --- a/packages/app-builder/cli/package.json +++ b/packages/app-builder/cli/package.json @@ -2,6 +2,9 @@ "name": "@effectify/app-builder-cli", "version": "0.0.0", "type": "module", + "bin": { + "effectify-app-builder": "./dist/src/main.js" + }, "dependencies": { "@effectify/app-builder-generation": "workspace:*", "tslib": "catalog:" diff --git a/packages/app-builder/cli/src/main.ts b/packages/app-builder/cli/src/main.ts index 319923fe..f7d6c219 100644 --- a/packages/app-builder/cli/src/main.ts +++ b/packages/app-builder/cli/src/main.ts @@ -1,4 +1,8 @@ +#!/usr/bin/env node + +import { realpathSync } from "node:fs" import { readFile } from "node:fs/promises" +import { pathToFileURL } from "node:url" import * as Effect from "effect/Effect" import { type CliCommand, @@ -171,3 +175,7 @@ export const runNodeCli = (args: ReadonlyArray = process.argv.slice(2)): process.exitCode = code return code }) + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { + await runNodeCli() +} diff --git a/packages/app-builder/e2e/project.json b/packages/app-builder/e2e/project.json index 12ad46fc..d26f4bda 100644 --- a/packages/app-builder/e2e/project.json +++ b/packages/app-builder/e2e/project.json @@ -21,6 +21,12 @@ }, "test": { "executor": "nx:run-commands", + "dependsOn": [ + { + "projects": ["@effectify/app-builder-cli"], + "target": "build" + } + ], "options": { "command": "../../../node_modules/.bin/vitest run --config vitest.config.mts", "cwd": "packages/app-builder/e2e" diff --git a/packages/app-builder/e2e/src/golden-workspace.ts b/packages/app-builder/e2e/src/golden-workspace.ts index 5beb12ce..d4bfbece 100644 --- a/packages/app-builder/e2e/src/golden-workspace.ts +++ b/packages/app-builder/e2e/src/golden-workspace.ts @@ -1,23 +1,28 @@ import { spawn, type ChildProcess } from "node:child_process" -import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" +import { createRequire } from "node:module" import { createServer } from "node:net" import { tmpdir } from "node:os" -import { dirname, join } from "node:path" +import { basename, dirname, join } from "node:path" import { fileURLToPath } from "node:url" -import * as Effect from "effect/Effect" const intent = { version: "effectify.creation-intent/1", preset: "todo", capabilities: ["todo.events"], + naming: { + workspace: "operations-workspace", + npmScope: "@acme", + domain: { id: "operations", name: "Operations" }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "admin-console", name: "AdminConsole" }, + }, } const commandTimeout = 90_000 const shutdownTimeout = 5_000 const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url)) -type Scenario = "interrupted" | "success" | "verification-failure" - export interface CommandEvidence { readonly argv: ReadonlyArray readonly exitCode: number @@ -31,16 +36,13 @@ export interface GoldenWorkspaceEvidence { readonly workspaceRemoved: boolean } readonly commands: ReadonlyArray - readonly outcome: Scenario + readonly outcome: "success" readonly regeneration: { readonly changedPaths: ReadonlyArray readonly secondWritePaths: ReadonlyArray } readonly rootProjectNames: ReadonlyArray - readonly todo: { - readonly events: ReadonlyArray - readonly state: "[]\n" - } + readonly task: { readonly events: ReadonlyArray; readonly id: string; readonly state: "[]\n" } } interface ProcessResult { @@ -54,22 +56,6 @@ interface WorkspaceState { readonly store: string } -interface GeneratedFile { - readonly content: string - readonly path: string -} - -interface GenerationModule { - readonly Planner: { - readonly planTodo: (input: unknown) => Effect.Effect - } - readonly TodoPreset: { - readonly createTodoTopology: ( - plan: unknown, - ) => Effect.Effect<{ readonly files: ReadonlyArray }, unknown> - } -} - const exists = async (path: string): Promise => stat(path) .then(() => true) @@ -93,6 +79,7 @@ const run = ( argv: ReadonlyArray, environment: NodeJS.ProcessEnv, timeout = commandTimeout, + input = "", ): Promise => new Promise((resolve, reject) => { const [command, ...args] = argv @@ -100,7 +87,7 @@ const run = ( reject(new Error("E2E subprocess requires an executable")) return } - const child = spawn(command, args, { cwd, env: environment, shell: false, stdio: ["ignore", "pipe", "pipe"] }) + const child = spawn(command, args, { cwd, env: environment, shell: false, stdio: ["pipe", "pipe", "pipe"] }) let stdout = "" let stderr = "" let timedOut = false @@ -117,6 +104,7 @@ const run = ( child.stderr.on("data", (chunk: string) => { stderr += chunk }) + child.stdin.end(input) child.once("error", (error) => { clearTimeout(timer) reject(error) @@ -171,96 +159,68 @@ const write = async (path: string, content: string): Promise => { await writeFile(path, content) } -const generation = () => - Effect.promise(() => import(new URL("../../generation/src/index.js", import.meta.url).href)) - -const project = (name: string, root: string) => - `${JSON.stringify({ name, projectType: root.startsWith("apps/") ? "application" : "library", root }, null, 2)}\n` - -const workspaceFiles = (): Readonly> => ({ - "nx.json": `${JSON.stringify({ defaultBase: "HEAD", plugins: [] }, null, 2)}\n`, - "package.json": `${JSON.stringify( - { - name: "effectify-todo-nested-e2e", - packageManager: "pnpm@10.14.0", - private: true, - devDependencies: { - "@effect/vitest": "4.0.0-beta.102", - "@types/node": "20.19.25", - nx: "23.1.0", - typescript: "6.0.3", - vitest: "4.1.10", - }, - }, - null, - 2, - )}\n`, - "pnpm-workspace.yaml": "packages:\n - apps/*\n - packages/*/*\n", - "project.json": `${JSON.stringify( - { - name: "@effectify/todo-workspace", - targets: { - build: { executor: "nx:run-commands", options: { command: "pnpm exec tsc -p tsconfig.build.json" } }, - test: { executor: "nx:run-commands", options: { command: "pnpm exec vitest run --config vitest.config.mts" } }, - typecheck: { - executor: "nx:run-commands", - options: { command: "pnpm exec tsc --noEmit -p tsconfig.build.json" }, - }, - }, - }, - null, - 2, - )}\n`, - "tsconfig.build.json": `${JSON.stringify( - { - compilerOptions: { - module: "NodeNext", - moduleResolution: "NodeNext", - outDir: "dist", - rootDir: ".", - skipLibCheck: true, - strict: true, - target: "ES2022", - types: ["node"], - }, - include: ["apps/**/src/**/*.ts", "packages/**/src/**/*.ts"], - }, - null, - 2, - )}\n`, - "vitest.config.mts": `import { defineConfig } from "vitest/config"\n\nexport default defineConfig({ test: { environment: "node", include: ["apps/**/tests/**/*.test.ts", "packages/**/tests/**/*.test.ts"], watch: false } })\n`, - "apps/todo-cli/project.json": project("@effectify/todo-cli", "apps/todo-cli"), - "packages/todo/application/project.json": project("@effectify/todo-application", "packages/todo/application"), - "packages/todo/domain/project.json": project("@effectify/todo-domain", "packages/todo/domain"), - "packages/todo/infrastructure/project.json": project( - "@effectify/todo-infrastructure", - "packages/todo/infrastructure", - ), -}) - -const proofTest = `import { expect, it } from "@effect/vitest"\nimport { readFile } from "node:fs/promises"\nimport { join } from "node:path"\nimport * as Effect from "effect/Effect"\nimport { createLiveRuntime, renderEvent } from "../src/index.js"\n\nit.effect("executes generated Todo CLI CRUD through the Live runtime", () => Effect.gen(function* () {\n const state = join(process.cwd(), ".todo-state.json")\n const todo = yield* createLiveRuntime(state)\n const added = yield* todo.add("write the nested proof")\n const completed = yield* todo.complete(added.id)\n const removed = yield* todo.remove(added.id)\n expect(yield* todo.list()).toEqual([])\n expect([added, completed, removed].map((value, index) => renderEvent(index === 0 ? { _tag: "TodoAdded", todo: value } : index === 1 ? { _tag: "TodoCompleted", todo: value } : { _tag: "TodoRemoved", todo: value }))).toEqual(["added:" + added.id + ":write the nested proof", "completed:" + added.id + ":write the nested proof", "removed:" + added.id + ":write the nested proof"])\n expect(yield* Effect.promise(() => readFile(state, "utf8"))).toBe("[]\\n")\n}))\n` +const generationRoot = createRequire(import.meta.url) + .resolve("@effectify/app-builder-generation") + .replace(/\/dist\/.*$/, "") +const packageRoots = ["contracts", "generation", "cli"].map((name) => join(dirname(generationRoot), name)) + +const installCli = async (workspace: string, registry: string, environment: NodeJS.ProcessEnv) => { + const tarballs: Array = [] + for (const root of packageRoots) { + const packed = await run(root, ["pnpm", "pack", "--pack-destination", join(workspace, "distribution")], environment) + if (packed.exitCode !== 0) throw new Error(`Unable to pack public CLI dependency: ${packed.stderr}`) + const tarball = packed.stdout.trim().split("\n").at(-1) + if (tarball === undefined) throw new Error("Package distribution did not produce a tarball") + tarballs.push(tarball) + } + const driver = join(workspace, "driver") + await write(join(driver, "package.json"), '{"private":true}\n') + const argv = ["npm", "install", "--registry", registry, ...tarballs, "effect@4.0.0-beta.102"] + const result = await run(driver, argv, environment) + if (result.exitCode !== 0) throw new Error(`Unable to install public CLI: ${result.stderr}${result.stdout}`) + return { argv, cli: join(driver, "node_modules/.bin/effectify-app-builder"), result } +} -const materialize = async (workspace: string, effectTarball: string): Promise> => { - const { Planner, TodoPreset } = await Effect.runPromise(generation()) - const plan = await Effect.runPromise(Planner.planTodo(intent)) - const topology = await Effect.runPromise(TodoPreset.createTodoTopology(plan)) - const written: Array = [] - const localEffect = `file:${join(workspace, "distribution", effectTarball)}` - for (const file of topology.files) { - const target = join(workspace, file.path) - const current = await readFile(target, "utf8").catch(() => undefined) - if (current === undefined) { - await write(target, file.content.replace('"effect": "catalog:"', `"effect": "${localEffect}"`)) - written.push(file.path) +const generate = async (workspace: string, cli: string, environment: NodeJS.ProcessEnv) => { + const argv = [cli, "generate", "--events=jsonl"] + const result = await run( + tmpdir(), + argv, + environment, + commandTimeout, + `${JSON.stringify({ + version: "effectify.app-builder-cli-request/1", + command: "generate", + payload: { intent, workspace: basename(workspace) }, + })}\n`, + ) + if (result.exitCode !== 0) throw new Error(`Public CLI generation failed: ${result.stderr}${result.stdout}`) + const terminal = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "null") as { + readonly terminal?: { + readonly _tag?: string + readonly result?: { readonly writtenPaths?: ReadonlyArray } } } - for (const [path, content] of Object.entries(workspaceFiles())) { - await write(join(workspace, path), content) + if (terminal.terminal?._tag !== "Success" || terminal.terminal.result?.writtenPaths === undefined) { + throw new Error("Public CLI generation did not return successful materialization evidence") } - await write(join(workspace, "apps/todo-cli/tests/nested-proof.test.ts"), proofTest) - return written + return { argv, result, writtenPaths: terminal.terminal.result.writtenPaths } } +const runtimeProof = ` +const { readFile } = await import("node:fs/promises") +const Effect = await import("effect/Effect") +const { createLiveRuntime, renderEvent } = await import("./src/index.ts") +const evidence = await Effect.runPromise(Effect.gen(function* () { + const task = yield* createLiveRuntime(".task-state.json") + const added = yield* task.add("write the public proof") + const completed = yield* task.complete(added.id) + const removed = yield* task.remove(added.id) + return { id: added.id, events: [renderEvent({ _tag: "TaskAdded", task: added }), renderEvent({ _tag: "TaskCompleted", task: completed }), renderEvent({ _tag: "TaskRemoved", task: removed })], remaining: yield* task.list(), state: yield* Effect.promise(() => readFile(".task-state.json", "utf8")) } +})) +console.log(JSON.stringify(evidence)) +` + const snapshot = async (workspace: string, paths: ReadonlyArray): Promise>> => Object.fromEntries( await Promise.all(paths.map(async (path) => [path, await readFile(join(workspace, path), "utf8")])), @@ -280,7 +240,6 @@ const readRootProjects = async (): Promise> => { const registryConfig = (workspace: string) => `storage: ${join(workspace, "registry/storage")}\nauth:\n htpasswd:\n file: ${join(workspace, "registry/htpasswd")}\nuplinks:\n npmjs:\n url: https://registry.npmjs.org/\npackages:\n "@*/*":\n access: $all\n publish: $all\n proxy: npmjs\n "**":\n access: $all\n publish: $all\n proxy: npmjs\nlog:\n - { type: stdout, format: pretty, level: warn }\n` -const effectPackagePath = fileURLToPath(new URL("../node_modules/effect/", import.meta.url)) const verdaccioPath = fileURLToPath( new URL( "../../../../node_modules/.pnpm/verdaccio@6.7.4_encoding@0.1.13_typanion@3.14.0/node_modules/verdaccio/bin/verdaccio", @@ -288,31 +247,18 @@ const verdaccioPath = fileURLToPath( ), ) -/** Runs a real, bounded nested Nx proof and always removes all temporary process and filesystem state. */ -export const runGoldenNestedWorkspace = async ({ - scenario = "success", -}: { readonly scenario?: Scenario } = {}): Promise => { +/** Runs a real installed public CLI proof and removes its isolated registry and files. */ +export const runGoldenNestedWorkspace = async (): Promise => { const workspace = await mkdtemp(join(tmpdir(), "effectify-app-builder-e2e-")) - const state: WorkspaceState = { - environment: environmentFor(workspace), - store: join(workspace, "pnpm-store"), - } + const state: WorkspaceState = { environment: environmentFor(workspace), store: join(workspace, "pnpm-store") } const commands: Array = [] let registry: ChildProcess | undefined - let outcome: Scenario = scenario let regeneration = { changedPaths: [] as ReadonlyArray, secondWritePaths: [] as ReadonlyArray } - let todo = { events: [] as ReadonlyArray, state: "[]\n" as const } + let task = { events: [] as ReadonlyArray, id: "", state: "[]\n" as const } let cleanup = { daemonStopped: false, storeRemoved: false, workspaceRemoved: false } try { await mkdir(state.environment.HOME!, { recursive: true }) - const distribution = join(workspace, "distribution") - await mkdir(distribution, { recursive: true }) - const packed = await run(effectPackagePath, ["pnpm", "pack", "--pack-destination", distribution], state.environment) - if (packed.exitCode !== 0) throw new Error(`Unable to pack local Effect distribution: ${packed.stderr}`) - const [effectTarball] = (await readdir(distribution)).filter((entry) => entry.endsWith(".tgz")) - if (effectTarball === undefined) throw new Error("Local Effect package distribution did not produce a tarball") - const port = await freePort() const config = join(workspace, "registry/config.yaml") await write(config, registryConfig(workspace)) @@ -322,87 +268,67 @@ export const runGoldenNestedWorkspace = async ({ shell: false, stdio: "ignore", }) - await waitForRegistry(`http://127.0.0.1:${port}`) + const registryUrl = `http://127.0.0.1:${port}` + await waitForRegistry(registryUrl) + + const installed = await installCli(workspace, registryUrl, state.environment) + commands.push({ argv: installed.argv, exitCode: installed.result.exitCode, label: "cli-install" }) + const firstGeneration = await generate(workspace, installed.cli, state.environment) + commands.push({ argv: firstGeneration.argv, exitCode: firstGeneration.result.exitCode, label: "generate" }) - const firstWritePaths = await materialize(workspace, effectTarball) - const lock = await run( - workspace, - ["pnpm", "install", "--lockfile-only", "--store-dir", state.store, "--registry", `http://127.0.0.1:${port}`], - state.environment, - ) + const registryArgs = ["--store-dir", state.store, "--registry", registryUrl] + const lock = await run(workspace, ["pnpm", "install", "--lockfile-only", ...registryArgs], state.environment) if (lock.exitCode !== 0) throw new Error(`Nested lockfile materialization failed: ${lock.stderr}${lock.stdout}`) - const installArgv = [ - "pnpm", - "install", - "--frozen-lockfile", - "--store-dir", - state.store, - "--registry", - `http://127.0.0.1:${port}`, - ] + const installArgv = ["pnpm", "install", "--frozen-lockfile", ...registryArgs] const install = await run(workspace, installArgv, state.environment) commands.push({ argv: installArgv, exitCode: install.exitCode, label: "install" }) if (install.exitCode !== 0) throw new Error(`Nested frozen install failed: ${install.stderr}${install.stdout}`) - if (scenario === "verification-failure") { - const failure = await run(workspace, [process.execPath, "--eval", "process.exit(1)"], state.environment) - commands.push({ - argv: [process.execPath, "--eval", "process.exit(1)"], - exitCode: failure.exitCode, - label: "verification-failure", - }) - if (failure.exitCode === 0) throw new Error("Nested verification failure scenario unexpectedly passed") - } else if (scenario === "interrupted") { - const interrupted = await run( - workspace, - [process.execPath, "--eval", "setInterval(() => undefined, 1000)"], - state.environment, - 100, - ) - commands.push({ - argv: [process.execPath, "--eval", "setInterval(() => undefined, 1000)"], - exitCode: interrupted.exitCode, - label: "interruption", - }) - if (interrupted.exitCode !== 130) throw new Error(`Nested interruption scenario returned ${interrupted.exitCode}`) - } else { + { const graphArgv = ["pnpm", "exec", "nx", "graph", "--print"] const graph = await run(workspace, graphArgv, state.environment) commands.push({ argv: graphArgv, exitCode: graph.exitCode, label: "graph" }) if (graph.exitCode !== 0) throw new Error(`Nested Nx graph failed: ${graph.stderr}`) - const names = Object.keys( + const projectNames = Object.keys( (JSON.parse(graph.stdout) as { readonly graph: { readonly nodes: Record } }).graph.nodes, - ) - for (const name of [ - "@effectify/todo-domain", - "@effectify/todo-application", - "@effectify/todo-infrastructure", - "@effectify/todo-cli", - ]) { - if (!names.includes(name)) throw new Error(`Nested Nx graph did not include ${name}`) + ).sort() + const expectedProjects = [ + "@acme/admin-console", + "@acme/operations-application", + "@acme/operations-domain", + "@acme/operations-infrastructure", + ] + if (JSON.stringify(projectNames) !== JSON.stringify(expectedProjects)) { + throw new Error(`Nested Nx graph did not match the expected projects: ${JSON.stringify(projectNames)}`) } + for (const label of ["test", "typecheck", "build"] as const) { - const argv = ["pnpm", "exec", "nx", "run", `@effectify/todo-workspace:${label}`] + const argv = ["pnpm", "exec", "nx", "run", `@acme/admin-console:${label}`] const result = await run(workspace, argv, state.environment) commands.push({ argv, exitCode: result.exitCode, label }) if (result.exitCode !== 0) throw new Error(`Nested ${label} failed: ${result.stderr}`) } - const beforeRegeneration = await snapshot(workspace, firstWritePaths) - const secondWritePaths = await materialize(workspace, effectTarball) - const afterRegeneration = await snapshot(workspace, firstWritePaths) - regeneration = { - changedPaths: firstWritePaths.filter((path) => beforeRegeneration[path] !== afterRegeneration[path]), - secondWritePaths, + const runtimeArgv = [process.execPath, "--input-type=module", "--eval", runtimeProof] + const runtime = await run(join(workspace, "apps/admin-console"), runtimeArgv, state.environment) + commands.push({ argv: runtimeArgv, exitCode: runtime.exitCode, label: "runtime" }) + if (runtime.exitCode !== 0) throw new Error(`Generated Live runtime failed: ${runtime.stderr}`) + const runtimeEvidence = JSON.parse(runtime.stdout) as { + readonly events: ReadonlyArray + readonly id: string + readonly remaining: ReadonlyArray + readonly state: "[]\n" } - todo = { - events: [ - "added:todo-1:write the nested proof", - "completed:todo-1:write the nested proof", - "removed:todo-1:write the nested proof", - ], - state: "[]\n", + if (runtimeEvidence.remaining.length !== 0) throw new Error("Generated Live runtime did not remove the Task") + + const beforeReplay = await snapshot(workspace, firstGeneration.writtenPaths) + const secondGeneration = await generate(workspace, installed.cli, state.environment) + commands.push({ argv: secondGeneration.argv, exitCode: secondGeneration.result.exitCode, label: "replay" }) + const afterReplay = await snapshot(workspace, firstGeneration.writtenPaths) + regeneration = { + changedPaths: firstGeneration.writtenPaths.filter((path) => beforeReplay[path] !== afterReplay[path]), + secondWritePaths: secondGeneration.writtenPaths, } - outcome = "success" + task = { events: runtimeEvidence.events, id: runtimeEvidence.id, state: runtimeEvidence.state } } } finally { const daemonStopped = registry === undefined ? true : await stop(registry) @@ -416,11 +342,11 @@ export const runGoldenNestedWorkspace = async ({ } const rootProjectNames = await readRootProjects() - if (rootProjectNames.some((name) => name.startsWith("@effectify/todo-"))) { - throw new Error("Nested Todo projects polluted root Nx discovery") + if (rootProjectNames.some((name) => name.startsWith("@acme/"))) { + throw new Error("Nested generated projects polluted root Nx discovery") } if (!cleanup.daemonStopped || !cleanup.storeRemoved || !cleanup.workspaceRemoved) { throw new Error("Nested E2E cleanup did not remove all isolated resources") } - return { cleanup, commands, outcome, regeneration, rootProjectNames, todo } + return { cleanup, commands, outcome: "success", regeneration, rootProjectNames, task } } diff --git a/packages/app-builder/e2e/tests/golden.e2e.test.ts b/packages/app-builder/e2e/tests/golden.e2e.test.ts index 7b5b6da0..a7368405 100644 --- a/packages/app-builder/e2e/tests/golden.e2e.test.ts +++ b/packages/app-builder/e2e/tests/golden.e2e.test.ts @@ -13,33 +13,38 @@ interface GoldenWorkspaceEvidence { readonly workspaceRemoved: boolean } readonly commands: ReadonlyArray - readonly outcome: "interrupted" | "success" | "verification-failure" + readonly outcome: "success" readonly regeneration: { readonly changedPaths: ReadonlyArray readonly secondWritePaths: ReadonlyArray } readonly rootProjectNames: ReadonlyArray - readonly todo: { + readonly task: { readonly events: ReadonlyArray + readonly id: string readonly state: "[]\n" } } interface GoldenWorkspaceModule { - readonly runGoldenNestedWorkspace: (input?: { - readonly scenario?: "interrupted" | "success" | "verification-failure" - }) => Promise + readonly runGoldenNestedWorkspace: () => Promise } const golden = () => import(new URL("../src/golden-workspace.js", import.meta.url).href) as Promise -it("R14 and S25-S27 generate an isolated Todo Nx workspace, run its real commands, and preserve zero-diff regeneration", async () => { +it("R14 and S25-S27 generate a custom workspace through the public CLI, run Nx and Live runtime, and replay cleanly", async () => { const Golden = await golden() const result = await Golden.runGoldenNestedWorkspace() expect(result.outcome).toBe("success") expect(result.commands).toEqual([ + expect.objectContaining({ label: "cli-install", exitCode: 0 }), + expect.objectContaining({ + label: "generate", + exitCode: 0, + argv: [expect.stringMatching(/\/effectify-app-builder$/), "generate", "--events=jsonl"], + }), expect.objectContaining({ label: "install", exitCode: 0, @@ -49,40 +54,33 @@ it("R14 and S25-S27 generate an isolated Todo Nx workspace, run its real command expect.objectContaining({ label: "test", exitCode: 0, - argv: ["pnpm", "exec", "nx", "run", "@effectify/todo-workspace:test"], + argv: ["pnpm", "exec", "nx", "run", "@acme/admin-console:test"], }), expect.objectContaining({ label: "typecheck", exitCode: 0, - argv: ["pnpm", "exec", "nx", "run", "@effectify/todo-workspace:typecheck"], + argv: ["pnpm", "exec", "nx", "run", "@acme/admin-console:typecheck"], }), expect.objectContaining({ label: "build", exitCode: 0, - argv: ["pnpm", "exec", "nx", "run", "@effectify/todo-workspace:build"], + argv: ["pnpm", "exec", "nx", "run", "@acme/admin-console:build"], + }), + expect.objectContaining({ label: "runtime", exitCode: 0 }), + expect.objectContaining({ + label: "replay", + exitCode: 0, + argv: [expect.stringMatching(/\/effectify-app-builder$/), "generate", "--events=jsonl"], }), ]) - expect(result.todo.events).toEqual([ - "added:todo-1:write the nested proof", - "completed:todo-1:write the nested proof", - "removed:todo-1:write the nested proof", + expect(result.task.id).toMatch(/^[0-9a-f-]{36}$/) + expect(result.task.events).toEqual([ + `added:${result.task.id}:write the public proof`, + `completed:${result.task.id}:write the public proof`, + `removed:${result.task.id}:write the public proof`, ]) - expect(result.todo.state).toBe("[]\n") + expect(result.task.state).toBe("[]\n") expect(result.regeneration).toEqual({ changedPaths: [], secondWritePaths: [] }) - expect(result.rootProjectNames.filter((name) => name.startsWith("@effectify/todo-"))).toEqual([]) + expect(result.rootProjectNames.filter((name) => name.startsWith("@acme/"))).toEqual([]) expect(result.cleanup).toEqual({ daemonStopped: true, storeRemoved: true, workspaceRemoved: true }) }, 300_000) - -it("R15 and S28 clean isolated resources after nested verification failure and interruption without root Nx pollution", async () => { - const Golden = await golden() - const verificationFailure = await Golden.runGoldenNestedWorkspace({ scenario: "verification-failure" }) - const interrupted = await Golden.runGoldenNestedWorkspace({ scenario: "interrupted" }) - - for (const result of [verificationFailure, interrupted]) { - expect(result.commands).toContainEqual(expect.objectContaining({ label: "install", exitCode: 0 })) - expect(result.cleanup).toEqual({ daemonStopped: true, storeRemoved: true, workspaceRemoved: true }) - expect(result.rootProjectNames.filter((name) => name.startsWith("@effectify/todo-"))).toEqual([]) - } - expect(verificationFailure.outcome).toBe("verification-failure") - expect(interrupted.outcome).toBe("interrupted") -}, 300_000) diff --git a/packages/app-builder/e2e/vitest.config.mts b/packages/app-builder/e2e/vitest.config.mts index da40e879..3ee89fa7 100644 --- a/packages/app-builder/e2e/vitest.config.mts +++ b/packages/app-builder/e2e/vitest.config.mts @@ -1,15 +1,8 @@ -import { fileURLToPath } from "node:url" import { defineConfig } from "vitest/config" export default defineConfig({ root: __dirname, cacheDir: "../../../node_modules/.vite/packages/app-builder/e2e", - resolve: { - alias: { - "@effectify/app-builder-contracts": fileURLToPath(new URL("../contracts/src/index.ts", import.meta.url)), - "@effectify/app-builder-generation": fileURLToPath(new URL("../generation/src/index.ts", import.meta.url)), - }, - }, test: { name: "@effectify/app-builder-e2e", environment: "node", diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/nx.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/nx.json.template index e053d2e7..d74ff27e 100644 --- a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/nx.json.template +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/nx.json.template @@ -3,6 +3,7 @@ "plugins": [ { "plugin": "@nx/js/typescript", + "exclude": ["tsconfig.build.json"], "options": { "typecheck": { "targetName": "typecheck" @@ -11,6 +12,7 @@ }, { "plugin": "@nx/vitest", + "exclude": ["vitest.config.mts"], "options": { "testTargetName": "test" } diff --git a/packages/app-builder/generation/tests/fixtures/todo-v1.ts b/packages/app-builder/generation/tests/fixtures/todo-v1.ts index 85e47700..e1219a6b 100644 --- a/packages/app-builder/generation/tests/fixtures/todo-v1.ts +++ b/packages/app-builder/generation/tests/fixtures/todo-v1.ts @@ -4,7 +4,7 @@ export const TodoV1Fixture: ReadonlyArray = Object.freeze( `apps/todo-cli/package.json|1f7e39c504515ed23d106e172ccc2f0ef91186021b3a02e9066fe14ea4e2783e|@effectify/app-builder/workspace/1|package-surface-presentation-manifest apps/todo-cli/src/index.ts|40f8ce0bfcb8db9c548ef19dd3509319e2c69ad03b27c57914d4788536b43eb5|@effectify/app-builder/presentation/1|package-surface-presentation-barrel apps/todo-cli/tests/todo.test.ts|77b9145ff6a68b6c4660948ab8066e3e3091500e61135cbba4f748395f3d8074|@effectify/app-builder/presentation/1|todo-presentation-apps-todo-cli-tests-todo-test-ts -nx.json|2f79ffcab40a80ced827e38e9871be1e666c3d648e4550ac09893459a8ab42a0|@effectify/app-builder/workspace/1|workspace-surface-nx-json +nx.json|36ebfd6eabb0aa4a5a4060dd694d745b9ce16717f4027bb9107c63b1e782af52|@effectify/app-builder/workspace/1|workspace-surface-nx-json package.json|3b369e67b69581211e14df72d231a2331399888fe8f16c5095bab03b4616e426|@effectify/app-builder/workspace/1|workspace-surface-package-json packages/todo/application/package.json|74a25e6b222c0e8e6ce8cf39df7e7a68d7b68faaaf01a1321d6d1c521f147f00|@effectify/app-builder/workspace/1|package-surface-application-manifest packages/todo/application/src/index.ts|a606cc63ce4e3ab6eeb2345cdaf9c4bbd3bfb43f5a20f26bff250439194a154b|@effectify/app-builder/port/1|package-surface-application-barrel From 54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7 Mon Sep 17 00:00:00 2001 From: Andres J Date: Thu, 13 Aug 2026 17:43:16 -0600 Subject: [PATCH 2/3] test(app-builder): prove CLI lifecycle cleanup --- .../app-builder/e2e/src/golden-workspace.ts | 289 +++++++++++++++--- .../app-builder/e2e/tests/golden.e2e.test.ts | 73 ++++- 2 files changed, 317 insertions(+), 45 deletions(-) diff --git a/packages/app-builder/e2e/src/golden-workspace.ts b/packages/app-builder/e2e/src/golden-workspace.ts index d4bfbece..766e5885 100644 --- a/packages/app-builder/e2e/src/golden-workspace.ts +++ b/packages/app-builder/e2e/src/golden-workspace.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcess } from "node:child_process" -import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" +import { appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { createRequire } from "node:module" import { createServer } from "node:net" import { tmpdir } from "node:os" @@ -23,10 +23,13 @@ const commandTimeout = 90_000 const shutdownTimeout = 5_000 const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url)) +type Scenario = "interrupted" | "success" | "verification-failure" + export interface CommandEvidence { readonly argv: ReadonlyArray readonly exitCode: number readonly label: string + readonly output?: string } export interface GoldenWorkspaceEvidence { @@ -36,19 +39,35 @@ export interface GoldenWorkspaceEvidence { readonly workspaceRemoved: boolean } readonly commands: ReadonlyArray - readonly outcome: "success" + readonly outcome: Scenario readonly regeneration: { readonly changedPaths: ReadonlyArray readonly secondWritePaths: ReadonlyArray } readonly rootProjectNames: ReadonlyArray readonly task: { readonly events: ReadonlyArray; readonly id: string; readonly state: "[]\n" } + readonly termination?: TerminationEvidence } interface ProcessResult { readonly exitCode: number readonly stderr: string readonly stdout: string + readonly termination?: TerminationEvidence +} + +interface TerminationEvidence { + readonly descendantPid?: number + readonly descendantGone: boolean + readonly directChildClosed: boolean + readonly directChildClosedBeforeKill: boolean + readonly escalated: boolean + readonly groupId: number + readonly groupGone: boolean + readonly killSent: boolean + readonly readinessObserved: boolean + readonly stopped: boolean + readonly termSent: boolean } interface WorkspaceState { @@ -63,6 +82,114 @@ const exists = async (path: string): Promise => const sleep = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)) +class ProcessTimeoutError extends Error {} + +const waitForClose = (closed: Promise, timeout: number): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + clearTimeout(timer) + reject(new ProcessTimeoutError(`Process did not close within ${timeout}ms`)) + }, timeout) + closed.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (error: unknown) => { + clearTimeout(timer) + reject(error) + }, + ) + }) + +const isAbsent = (error: unknown): boolean => + typeof error === "object" && error !== null && Reflect.get(error, "code") === "ESRCH" + +const signalGroup = (pgid: number, signal: NodeJS.Signals): boolean => { + try { + process.kill(-pgid, signal) + return true + } catch (error) { + if (isAbsent(error)) return false + throw error + } +} + +const processExists = (pid: number): boolean => { + try { + process.kill(pid, 0) + return true + } catch (error) { + if (isAbsent(error)) return false + if (typeof error === "object" && error !== null && Reflect.get(error, "code") === "EPERM") return true + throw error + } +} + +const waitUntil = async (predicate: () => boolean, timeout: number): Promise => { + const deadline = Date.now() + timeout + while (!predicate()) { + if (Date.now() >= deadline) return false + await sleep(25) + } + return true +} + +const waitForReadiness = async (path: string, assertRunning: () => void): Promise => { + const deadline = Date.now() + commandTimeout + while (Date.now() < deadline) { + assertRunning() + try { + const value = JSON.parse(await readFile(path, "utf8")) as { readonly descendantPid?: unknown } + if (typeof value.descendantPid === "number" && value.descendantPid > 0) return value.descendantPid + } catch { + // The generated Vitest test has not completed its descendant handshake yet. + } + await sleep(25) + } + throw new Error("Generated Vitest workflow did not publish deterministic readiness evidence") +} + +const terminateGroup = async ( + pgid: number, + directClose: Promise, + isDirectChildClosed: () => boolean, + readinessObserved: boolean, + descendantPid?: number, +): Promise => { + const termSent = signalGroup(pgid, "SIGTERM") + let groupGone = await waitUntil(() => !processExists(-pgid), shutdownTimeout) + const escalated = !groupGone + const directChildClosedBeforeKill = isDirectChildClosed() + let killSent = false + if (escalated) { + killSent = signalGroup(pgid, "SIGKILL") + groupGone = await waitUntil(() => !processExists(-pgid), shutdownTimeout) + } + const directChildClosed = await waitForClose(directClose, shutdownTimeout).then( + () => true, + (error) => { + if (error instanceof ProcessTimeoutError) return false + throw error + }, + ) + const descendantGone = + descendantPid === undefined || (await waitUntil(() => !processExists(descendantPid), shutdownTimeout)) + return { + descendantPid, + descendantGone, + directChildClosed, + directChildClosedBeforeKill, + escalated, + groupId: pgid, + groupGone, + killSent, + readinessObserved, + stopped: directChildClosed && groupGone && descendantGone, + termSent, + } +} + const environmentFor = (workspace: string): NodeJS.ProcessEnv => ({ CI: "true", HOME: join(workspace, "home"), @@ -74,57 +201,98 @@ const environmentFor = (workspace: string): NodeJS.ProcessEnv => ({ PNPM_HOME: join(workspace, "pnpm-home"), }) -const run = ( +const run = async ( cwd: string, argv: ReadonlyArray, environment: NodeJS.ProcessEnv, timeout = commandTimeout, input = "", -): Promise => - new Promise((resolve, reject) => { - const [command, ...args] = argv - if (command === undefined) { - reject(new Error("E2E subprocess requires an executable")) - return - } - const child = spawn(command, args, { cwd, env: environment, shell: false, stdio: ["pipe", "pipe", "pipe"] }) - let stdout = "" - let stderr = "" - let timedOut = false - const timer = setTimeout(() => { - timedOut = true - child.kill("SIGTERM") - setTimeout(() => child.kill("SIGKILL"), shutdownTimeout).unref() - }, timeout) - child.stdout.setEncoding("utf8") - child.stderr.setEncoding("utf8") - child.stdout.on("data", (chunk: string) => { - stdout += chunk - }) - child.stderr.on("data", (chunk: string) => { - stderr += chunk - }) - child.stdin.end(input) + readinessPath?: string, +): Promise => { + const [command, ...args] = argv + if (command === undefined) throw new Error("E2E subprocess requires an executable") + const child = spawn(command, args, { + cwd, + detached: true, + env: environment, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }) + let directChildClosed = false + let childError: unknown + const closed = new Promise((resolve, reject) => { child.once("error", (error) => { - clearTimeout(timer) + childError = error reject(error) }) child.once("close", (code) => { - clearTimeout(timer) - resolve({ exitCode: timedOut ? 130 : (code ?? 1), stderr, stdout }) + directChildClosed = true + resolve(code) }) }) + void closed.catch(() => undefined) + if (child.pid === undefined) { + await closed + throw new Error("E2E subprocess did not expose a process-group identifier") + } + const pgid = child.pid + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk: string) => { + stdout += chunk + }) + child.stderr.on("data", (chunk: string) => { + stderr += chunk + }) + child.stdin.end(input) + + if (readinessPath !== undefined) { + let descendantPid: number + try { + descendantPid = await waitForReadiness(readinessPath, () => { + if (childError !== undefined) throw childError + if (directChildClosed) throw new Error("Generated Vitest workflow closed before publishing readiness evidence") + }) + } catch (error) { + const cleanup = await terminateGroup(pgid, closed, () => directChildClosed, false) + if (!cleanup.stopped) + throw new Error(`Unready generated workflow process group did not stop: ${JSON.stringify(cleanup)}`) + throw error + } + const termination = await terminateGroup(pgid, closed, () => directChildClosed, true, descendantPid) + if (!termination.stopped) + throw new Error(`Generated workflow process group did not stop: ${JSON.stringify(termination)}`) + return { exitCode: 130, stderr, stdout, termination } + } + + try { + const completed = await waitForClose(closed, timeout) + return { exitCode: completed ?? 1, stderr, stdout } + } catch (error) { + const termination = await terminateGroup(pgid, closed, () => directChildClosed, false) + if (!termination.stopped) throw new Error(`Failed process group did not stop: ${JSON.stringify(termination)}`) + throw error + } +} + +export const runGoldenCommandTimeout = (timeout = 50): Promise => + run( + tmpdir(), + [process.execPath, "-e", 'require("node:net").createServer().listen(0, "127.0.0.1")'], + process.env, + timeout, + ) const stop = async (child: ChildProcess): Promise => { - if (child.exitCode !== null) return true - child.kill("SIGTERM") - const stopped = await Promise.race([ - new Promise((resolve) => child.once("close", () => resolve(true))), - sleep(shutdownTimeout).then(() => false), - ]) - if (stopped) return true - child.kill("SIGKILL") - return new Promise((resolve) => child.once("close", () => resolve(true))) + if (child.pid === undefined) return true + const pgid = child.pid + if (!processExists(-pgid)) return true + signalGroup(pgid, "SIGTERM") + if (await waitUntil(() => !processExists(-pgid), shutdownTimeout)) return true + signalGroup(pgid, "SIGKILL") + return waitUntil(() => !processExists(-pgid), shutdownTimeout) } const freePort = (): Promise => @@ -248,13 +416,17 @@ const verdaccioPath = fileURLToPath( ) /** Runs a real installed public CLI proof and removes its isolated registry and files. */ -export const runGoldenNestedWorkspace = async (): Promise => { +export const runGoldenNestedWorkspace = async ({ + scenario = "success", +}: { readonly scenario?: Scenario } = {}): Promise => { const workspace = await mkdtemp(join(tmpdir(), "effectify-app-builder-e2e-")) const state: WorkspaceState = { environment: environmentFor(workspace), store: join(workspace, "pnpm-store") } const commands: Array = [] let registry: ChildProcess | undefined + let outcome = scenario let regeneration = { changedPaths: [] as ReadonlyArray, secondWritePaths: [] as ReadonlyArray } let task = { events: [] as ReadonlyArray, id: "", state: "[]\n" as const } + let termination: TerminationEvidence | undefined let cleanup = { daemonStopped: false, storeRemoved: false, workspaceRemoved: false } try { @@ -264,6 +436,7 @@ export const runGoldenNestedWorkspace = async (): Promise {\n const { spawn } = await import("node:child_process")\n const descendant = spawn(process.execPath, ["-e", ${JSON.stringify(`const { writeFileSync } = require("node:fs"); const { createServer } = require("node:net"); process.on("SIGTERM", () => {}); createServer().listen(0, "127.0.0.1", () => writeFileSync(${JSON.stringify(ready)}, JSON.stringify({ descendantPid: process.pid })))`)}], { stdio: "ignore" })\n if (descendant.pid === undefined) throw new Error("resistant descendant did not start")\n await new Promise((resolve) => descendant.once("exit", resolve))\n})\n`, + ) + const argv = ["pnpm", "exec", "nx", "run", "@acme/admin-console:test"] + const interrupted = await run(workspace, argv, state.environment, commandTimeout, "", ready) + termination = interrupted.termination + commands.push({ argv, exitCode: interrupted.exitCode, label: "interruption" }) + if (interrupted.exitCode !== 130 || termination?.stopped !== true || termination.escalated !== true) { + throw new Error( + `Generated Vitest interruption lacked truthful termination evidence: ${JSON.stringify(termination)}`, + ) + } + } else { const graphArgv = ["pnpm", "exec", "nx", "graph", "--print"] const graph = await run(workspace, graphArgv, state.environment) commands.push({ argv: graphArgv, exitCode: graph.exitCode, label: "graph" }) @@ -329,6 +531,7 @@ export const runGoldenNestedWorkspace = async (): Promise readonly exitCode: number readonly label: string + readonly output?: string } interface GoldenWorkspaceEvidence { @@ -13,7 +14,7 @@ interface GoldenWorkspaceEvidence { readonly workspaceRemoved: boolean } readonly commands: ReadonlyArray - readonly outcome: "success" + readonly outcome: "interrupted" | "success" | "verification-failure" readonly regeneration: { readonly changedPaths: ReadonlyArray readonly secondWritePaths: ReadonlyArray @@ -24,10 +25,26 @@ interface GoldenWorkspaceEvidence { readonly id: string readonly state: "[]\n" } + readonly termination?: { + readonly descendantPid?: number + readonly descendantGone: boolean + readonly directChildClosed: boolean + readonly directChildClosedBeforeKill: boolean + readonly escalated: boolean + readonly groupId: number + readonly groupGone: boolean + readonly killSent: boolean + readonly readinessObserved: boolean + readonly stopped: boolean + readonly termSent: boolean + } } interface GoldenWorkspaceModule { - readonly runGoldenNestedWorkspace: () => Promise + readonly runGoldenCommandTimeout: (timeout?: number) => Promise + readonly runGoldenNestedWorkspace: (options?: { + readonly scenario?: GoldenWorkspaceEvidence["outcome"] + }) => Promise } const golden = () => @@ -84,3 +101,55 @@ it("R14 and S25-S27 generate a custom workspace through the public CLI, run Nx a expect(result.rootProjectNames.filter((name) => name.startsWith("@acme/"))).toEqual([]) expect(result.cleanup).toEqual({ daemonStopped: true, storeRemoved: true, workspaceRemoved: true }) }, 300_000) + +it("rejects a non-closing process on timeout without retaining its timer", async () => { + const timeoutCount = process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length + + await expect((await golden()).runGoldenCommandTimeout(50)).rejects.toThrow("Process did not close within 50ms") + expect(process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length).toBeLessThanOrEqual( + timeoutCount, + ) +}) + +it("reports a real generated Nx typecheck failure and removes its isolated resources", async () => { + const timeoutCount = process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length + const result = await (await golden()).runGoldenNestedWorkspace({ scenario: "verification-failure" }) + + expect(result.outcome).toBe("verification-failure") + const failure = result.commands.find((command) => command.label === "verification-failure") + expect(failure?.argv).toEqual(["pnpm", "exec", "nx", "run", "@acme/admin-console:typecheck"]) + expect(failure?.exitCode).not.toBe(0) + expect(failure?.output).toContain("src/index.ts") + expect(failure?.output).toContain("TS2322") + expect(result.rootProjectNames.filter((name) => name.startsWith("@acme/"))).toEqual([]) + expect(result.cleanup).toEqual({ daemonStopped: true, storeRemoved: true, workspaceRemoved: true }) + expect(process.getActiveResourcesInfo().filter((resource) => resource === "Timeout").length).toBeLessThanOrEqual( + timeoutCount, + ) +}, 300_000) + +it("interrupts a ready generated Vitest workflow and kills its TERM-resistant descendant", async () => { + const result = await (await golden()).runGoldenNestedWorkspace({ scenario: "interrupted" }) + + expect(result.outcome).toBe("interrupted") + expect(result.commands).toContainEqual({ + argv: ["pnpm", "exec", "nx", "run", "@acme/admin-console:test"], + exitCode: 130, + label: "interruption", + }) + expect(result.termination).toEqual({ + descendantPid: expect.any(Number), + descendantGone: true, + directChildClosed: true, + directChildClosedBeforeKill: true, + escalated: true, + groupId: expect.any(Number), + groupGone: true, + killSent: true, + readinessObserved: true, + stopped: true, + termSent: true, + }) + expect(result.rootProjectNames.filter((name) => name.startsWith("@acme/"))).toEqual([]) + expect(result.cleanup).toEqual({ daemonStopped: true, storeRemoved: true, workspaceRemoved: true }) +}, 300_000) From 7d3493c5352fe81fb4a72277978f398af7ec50cb Mon Sep 17 00:00:00 2001 From: Andres J Date: Fri, 14 Aug 2026 07:57:00 -0600 Subject: [PATCH 3/3] feat(app-builder): gate showcase regeneration --- packages/app-builder/generation/src/index.ts | 1 + .../generation/src/regeneration.ts | 89 ++++++++++ .../generation/tests/regeneration.test.ts | 161 ++++++++++++++++++ 3 files changed, 251 insertions(+) create mode 100644 packages/app-builder/generation/src/regeneration.ts create mode 100644 packages/app-builder/generation/tests/regeneration.test.ts diff --git a/packages/app-builder/generation/src/index.ts b/packages/app-builder/generation/src/index.ts index 1a55ae3e..793a4e04 100644 --- a/packages/app-builder/generation/src/index.ts +++ b/packages/app-builder/generation/src/index.ts @@ -3,6 +3,7 @@ export * as GenerationKernel from "./kernel.js" export { composeCatalog } from "./catalog.js" export * as Intent from "./intent.js" export * as Planner from "./planner.js" +export * as Pr130Regeneration from "./regeneration.js" export * as Replay from "./replay.js" export * as TodoEvolution from "./evolution.js" export * as TodoGeneration from "./generators/index.js" diff --git a/packages/app-builder/generation/src/regeneration.ts b/packages/app-builder/generation/src/regeneration.ts new file mode 100644 index 00000000..86673f75 --- /dev/null +++ b/packages/app-builder/generation/src/regeneration.ts @@ -0,0 +1,89 @@ +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" + +export const Pr130EvidenceClass = Schema.Literals([ + "unit-1-replay-completeness", + "unit-2-typed-kernel-catalog", + "unit-3-generation-surfaces", + "unit-4-atomic-todo-materialization", + "unit-5-todo-v1-parity", + "unit-6-public-cli-lifecycle", +]) +export type Pr130EvidenceClass = typeof Pr130EvidenceClass.Type +const CanonicalEvidenceClasses = Object.freeze(Pr130EvidenceClass.literals) + +export const Pr130FrozenHead = "d5928f99a122b04cb70ff68bfa02fa2d5648a5fc" + +export const Pr130ExpectedEvidenceIdentities: Readonly> = Object.freeze({ + "unit-1-replay-completeness": "613a9654a183910956cc72153f7f8c33b3a78282", + "unit-2-typed-kernel-catalog": "b85aabf05d6cb800e017cd5f4dc4e2ccc6a03e7f", + "unit-3-generation-surfaces": "76aab96389f0f0c7c4ab02b20dbf53b7e60abcfa", + "unit-4-atomic-todo-materialization": "21b92cc564ac806a82e50640552fdf8f0d6d674a", + "unit-5-todo-v1-parity": "2ad43e0cca44a0ed34db27fa29b344a71fc8e214", + "unit-6-public-cli-lifecycle": "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", +}) + +export const Pr130RegenerationProof = Schema.Struct({ + version: Schema.Literal("effectify.app-builder-pr130-regeneration-proof/1"), + evidence: Schema.Array( + Schema.Struct({ + evidenceClass: Pr130EvidenceClass, + identity: Schema.String, + }), + ), + pr130Head: Schema.String, +}) +export type Pr130RegenerationProof = typeof Pr130RegenerationProof.Type + +export const Pr130RegenerationBlockReason = Schema.TaggedUnion({ + MissingEvidence: { evidenceClass: Pr130EvidenceClass }, + MismatchedEvidence: { + actualIdentities: Schema.Array(Schema.String), + evidenceClass: Pr130EvidenceClass, + expectedIdentity: Schema.String, + }, + WrongPr130Head: { actualHead: Schema.String, expectedHead: Schema.String }, +}) +export type Pr130RegenerationBlockReason = typeof Pr130RegenerationBlockReason.Type + +export class Pr130RegenerationBlocked extends Data.TaggedError("Pr130RegenerationBlocked")<{ + readonly reasons: ReadonlyArray +}> {} + +export interface Pr130RegenerationEligibility { + readonly prNumber: 130 + readonly status: "eligible" +} + +const eligibility: Pr130RegenerationEligibility = Object.freeze({ prNumber: 130, status: "eligible" }) + +/** Evaluates canonical evidence only; it owns no external read or mutation capability. */ +export const requirePr130RegenerationEligibility = ( + proof: Pr130RegenerationProof, +): Effect.Effect => { + const reasons: Array = [] + for (const evidenceClass of CanonicalEvidenceClasses) { + const expectedIdentity = Pr130ExpectedEvidenceIdentities[evidenceClass] + const actualIdentities = proof.evidence + .filter((evidence) => evidence.evidenceClass === evidenceClass) + .map((evidence) => evidence.identity) + .sort() + if (actualIdentities.length === 0) { + reasons.push({ _tag: "MissingEvidence", evidenceClass }) + } else if (actualIdentities.length !== 1 || actualIdentities[0] !== expectedIdentity) { + reasons.push({ + _tag: "MismatchedEvidence", + actualIdentities: Object.freeze(actualIdentities), + evidenceClass, + expectedIdentity, + }) + } + } + if (proof.pr130Head !== Pr130FrozenHead) { + reasons.push({ _tag: "WrongPr130Head", actualHead: proof.pr130Head, expectedHead: Pr130FrozenHead }) + } + return reasons.length === 0 + ? Effect.succeed(eligibility) + : Effect.fail(new Pr130RegenerationBlocked({ reasons: Object.freeze(reasons) })) +} diff --git a/packages/app-builder/generation/tests/regeneration.test.ts b/packages/app-builder/generation/tests/regeneration.test.ts new file mode 100644 index 00000000..6a038cc7 --- /dev/null +++ b/packages/app-builder/generation/tests/regeneration.test.ts @@ -0,0 +1,161 @@ +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Result from "effect/Result" +import * as Generation from "../src/index.js" +import { + Pr130EvidenceClass, + Pr130FrozenHead, + type Pr130RegenerationProof, + requirePr130RegenerationEligibility, +} from "../src/regeneration.js" + +const ExpectedCanonicalEvidence: Pr130RegenerationProof["evidence"] = Object.freeze([ + { + evidenceClass: "unit-1-replay-completeness", + identity: "613a9654a183910956cc72153f7f8c33b3a78282", + }, + { + evidenceClass: "unit-2-typed-kernel-catalog", + identity: "b85aabf05d6cb800e017cd5f4dc4e2ccc6a03e7f", + }, + { + evidenceClass: "unit-3-generation-surfaces", + identity: "76aab96389f0f0c7c4ab02b20dbf53b7e60abcfa", + }, + { + evidenceClass: "unit-4-atomic-todo-materialization", + identity: "21b92cc564ac806a82e50640552fdf8f0d6d674a", + }, + { + evidenceClass: "unit-5-todo-v1-parity", + identity: "2ad43e0cca44a0ed34db27fa29b344a71fc8e214", + }, + { + evidenceClass: "unit-6-public-cli-lifecycle", + identity: "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", + }, +]) +const ExpectedCanonicalClasses = Object.freeze(ExpectedCanonicalEvidence.map(({ evidenceClass }) => evidenceClass)) +const ExpectedPr130Head = "d5928f99a122b04cb70ff68bfa02fa2d5648a5fc" + +const completeProof = (): Pr130RegenerationProof => ({ + version: "effectify.app-builder-pr130-regeneration-proof/1", + evidence: ExpectedCanonicalEvidence.map((evidence) => ({ ...evidence })), + pr130Head: ExpectedPr130Head, +}) + +it.effect("incomplete canonical proof blocks PR #130 regeneration before effects", () => + Effect.gen(function* () { + const failure = yield* requirePr130RegenerationEligibility({ + ...completeProof(), + evidence: [], + }).pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "Pr130RegenerationBlocked" }) + expect(failure.reasons).toEqual( + ExpectedCanonicalClasses.map((evidenceClass) => ({ _tag: "MissingEvidence", evidenceClass })), + ) + }), +) + +it.effect("each canonical Unit evidence class is independently required", () => + Effect.gen(function* () { + for (const missing of ExpectedCanonicalClasses) { + const proof = completeProof() + const failure = yield* requirePr130RegenerationEligibility({ + ...proof, + evidence: proof.evidence.filter(({ evidenceClass }) => evidenceClass !== missing), + }).pipe(Effect.flip) + + expect(failure.reasons).toEqual([{ _tag: "MissingEvidence", evidenceClass: missing }]) + } + }), +) + +it.effect("mismatched evidence identities block PR #130 regeneration deterministically", () => + Effect.gen(function* () { + const evidence = completeProof() + .evidence.map(({ evidenceClass }) => ({ evidenceClass, identity: `stale:${evidenceClass}` })) + .reverse() + const failure = yield* requirePr130RegenerationEligibility({ ...completeProof(), evidence }).pipe(Effect.flip) + + expect(failure.reasons).toEqual( + ExpectedCanonicalEvidence.map(({ evidenceClass, identity }) => ({ + _tag: "MismatchedEvidence", + actualIdentities: [`stale:${evidenceClass}`], + evidenceClass, + expectedIdentity: identity, + })), + ) + }), +) + +it.effect("wrong frozen PR #130 head blocks eligibility", () => + Effect.gen(function* () { + const failure = yield* requirePr130RegenerationEligibility({ + ...completeProof(), + pr130Head: "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", + }).pipe(Effect.flip) + + expect(failure.reasons).toEqual([ + { + _tag: "WrongPr130Head", + actualHead: "54b9677b541cdb4fb3f0be11d5b4e77914f8a4c7", + expectedHead: Pr130FrozenHead, + }, + ]) + }), +) + +it.effect("complete canonical proof returns regeneration eligibility only", () => + Effect.gen(function* () { + const proof = Object.freeze({ + ...completeProof(), + evidence: Object.freeze(completeProof().evidence.map((entry) => Object.freeze(entry))), + }) + const before = structuredClone(proof) + const eligibility = yield* requirePr130RegenerationEligibility(proof) + + expect(eligibility).toEqual({ prNumber: 130, status: "eligible" }) + expect(proof).toEqual(before) + }), +) + +it("frozen PR #130 head remains typed input evidence only", () => { + const source = readFileSync(fileURLToPath(new URL("../src/regeneration.ts", import.meta.url)), "utf8") + + expect(Pr130FrozenHead).toBe("d5928f99a122b04cb70ff68bfa02fa2d5648a5fc") + expect(source).not.toMatch(/node:|github|octokit|\bfetch\b|Effect\.(promise|tryPromise)/i) +}) + +it.effect("public evidence metadata cannot weaken the canonical Unit 6 requirement", () => + Effect.gen(function* () { + const publicClasses = Pr130EvidenceClass.literals + const original = [...publicClasses] + const deleted = Reflect.deleteProperty(publicClasses, "5") + const shortened = Reflect.set(publicClasses, "length", 5) + const outcome = yield* Effect.result( + requirePr130RegenerationEligibility({ + ...completeProof(), + evidence: ExpectedCanonicalEvidence.filter( + ({ evidenceClass }) => evidenceClass !== "unit-6-public-cli-lifecycle", + ), + }), + ) + for (const [index, evidenceClass] of original.entries()) { + Reflect.set(publicClasses, String(index), evidenceClass) + } + Reflect.set(publicClasses, "length", original.length) + + expect(Result.isFailure(outcome)).toBe(true) + if (Result.isFailure(outcome)) { + expect(outcome.failure.reasons).toEqual([ + { _tag: "MissingEvidence", evidenceClass: "unit-6-public-cli-lifecycle" }, + ]) + } + expect("Pr130EvidenceClasses" in Generation.Pr130Regeneration).toBe(false) + expect([deleted, shortened, Object.isFrozen(publicClasses)]).toEqual([false, false, true]) + }), +)