diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1da45d0..5dfb0ad1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -291,11 +291,30 @@ jobs: mkdir -p "$workspace" node packages/app-builder/execution/demo/deny-network.cjs -- pnpm nx run @effectify/app-builder-execution:executable -- --workspace "$workspace" --approve + app-builder-showcase: + name: App Builder showcase drift check + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v6 + with: + version: 10.14.0 + - uses: actions/setup-node@v5 + with: + node-version: "22.22.0" + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Build the public generate command + run: pnpm nx run @effectify/app-builder-cli:build --skipNxCache + - name: Regenerate through the installed public CLI and compare + run: node tools/check-app-builder-showcase.mjs --expected examples/app-builder-todo --output "$RUNNER_TEMP/app-builder-todo" + # Summary job ci-summary: name: ๐Ÿ“Š CI Summary runs-on: ubuntu-latest - needs: [lint, typecheck, build, test] + needs: [lint, typecheck, build, test, app-builder-showcase] if: always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) steps: - name: ๐Ÿ“Š CI Summary @@ -307,6 +326,7 @@ jobs: echo "| ๐Ÿ” Type Check | ${{ needs.typecheck.result == 'success' && 'โœ… Success' || 'โŒ Failed' }} | TypeScript compiler checks |" >> $GITHUB_STEP_SUMMARY echo "| ๐Ÿ—๏ธ Build | ${{ needs.build.result == 'success' && 'โœ… Success' || 'โŒ Failed' }} | Nx affected build |" >> $GITHUB_STEP_SUMMARY echo "| ๐Ÿงช Test | ${{ needs.test.result == 'success' && 'โœ… Success' || 'โŒ Failed' }} | Unit tests with Vitest |" >> $GITHUB_STEP_SUMMARY + echo "| ๐Ÿงฉ App Builder showcase | ${{ needs.app-builder-showcase.result == 'success' && 'โœ… Success' || 'โŒ Failed' }} | Installed public generate drift check |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### ๐Ÿ“ˆ Performance Metrics" >> $GITHUB_STEP_SUMMARY diff --git a/.nxignore b/.nxignore index 4f0c27bc..5d761a76 100644 --- a/.nxignore +++ b/.nxignore @@ -1 +1,2 @@ -.effect-reference/ \ No newline at end of file +.effect-reference/ +examples/app-builder-todo/** diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 8b6c08c1..3ed370e5 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -14,6 +14,7 @@ "sortImports": false, "sortPackageJson": false, "ignorePatterns": [ + "examples/app-builder-todo/**", "**/dist/**", "**/build/**", "**/.nx/**", diff --git a/examples/app-builder-todo/.effectify/app-builder/showcase.json b/examples/app-builder-todo/.effectify/app-builder/showcase.json new file mode 100644 index 00000000..cff123bf --- /dev/null +++ b/examples/app-builder-todo/.effectify/app-builder/showcase.json @@ -0,0 +1,140 @@ +{ + "version": "effectify.app-builder-showcase/1", + "command": "generate", + "request": { + "version": "effectify.app-builder-cli-request/1", + "command": "generate", + "payload": { + "intent": { + "version": "effectify.creation-intent/1", + "preset": "todo", + "capabilities": ["todo.events"] + }, + "workspace": "app-builder-todo" + } + }, + "provenance": { + "canonicalJson": "effectify-cjson/1", + "evidence": { + "blocks": "sha256:84da002bcb1cdf7ba5d492806f02cbb1fffbd0a5e872c452ffb514997153456b", + "catalog": "sha256:9a279207ce20bee7378d3e0ea2cfaf31de996a5371b2be07b425d50a95dbe2ad", + "dependencies": "sha256:4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "intent": "sha256:96b9f1671258586740db7423e0d5e5971a3e9eef8f5e24ac8686efbf9b405c01", + "outputs": "sha256:1d502d90d9bb8fcc603544c9acf46ab8e57b2433a6076300013601f0a3ca0ad9", + "pins": "sha256:01444b6cd4c1748f19561ca13a2d58c2c7d88aa479fb6100d88d230d0deffdb0", + "plan": "sha256:0db8472dc2518641b89e560304c60f7172939da780346403b5931c2c7f3d070b" + }, + "outputIdentities": [ + { + "mode": "100644", + "owner": "package-surface-presentation-manifest", + "path": "apps/todo-cli/package.json", + "sourceDigest": "sha256:1f7e39c504515ed23d106e172ccc2f0ef91186021b3a02e9066fe14ea4e2783e" + }, + { + "mode": "100644", + "owner": "package-surface-presentation-barrel", + "path": "apps/todo-cli/src/index.ts", + "sourceDigest": "sha256:40f8ce0bfcb8db9c548ef19dd3509319e2c69ad03b27c57914d4788536b43eb5" + }, + { + "mode": "100644", + "owner": "todo-presentation-apps-todo-cli-tests-todo-test-ts", + "path": "apps/todo-cli/tests/todo.test.ts", + "sourceDigest": "sha256:77b9145ff6a68b6c4660948ab8066e3e3091500e61135cbba4f748395f3d8074" + }, + { + "mode": "100644", + "owner": "workspace-surface-nx-json", + "path": "nx.json", + "sourceDigest": "sha256:36ebfd6eabb0aa4a5a4060dd694d745b9ce16717f4027bb9107c63b1e782af52" + }, + { + "mode": "100644", + "owner": "workspace-surface-package-json", + "path": "package.json", + "sourceDigest": "sha256:3b369e67b69581211e14df72d231a2331399888fe8f16c5095bab03b4616e426" + }, + { + "mode": "100644", + "owner": "package-surface-application-manifest", + "path": "packages/todo/application/package.json", + "sourceDigest": "sha256:74a25e6b222c0e8e6ce8cf39df7e7a68d7b68faaaf01a1321d6d1c521f147f00" + }, + { + "mode": "100644", + "owner": "package-surface-application-barrel", + "path": "packages/todo/application/src/index.ts", + "sourceDigest": "sha256:a606cc63ce4e3ab6eeb2345cdaf9c4bbd3bfb43f5a20f26bff250439194a154b" + }, + { + "mode": "100644", + "owner": "todo-use-case-packages-todo-application-src-use-case-ts", + "path": "packages/todo/application/src/use-case.ts", + "sourceDigest": "sha256:fce13645ef2926c343137b96fd0b2a0cd9b2e73046d8608ff3daa7204755c7df" + }, + { + "mode": "100644", + "owner": "package-surface-domain-manifest", + "path": "packages/todo/domain/package.json", + "sourceDigest": "sha256:9ed2f277b6b9bf848e1c077d7a600aea00c391a05d558cc6ba0ca8134023baca" + }, + { + "mode": "100644", + "owner": "todo-event-packages-todo-domain-src-events-ts", + "path": "packages/todo/domain/src/events.ts", + "sourceDigest": "sha256:3826f139fcac911e8b84298d319400e2265730c62460b40501b3c1668be950f8" + }, + { + "mode": "100644", + "owner": "package-surface-domain-barrel", + "path": "packages/todo/domain/src/index.ts", + "sourceDigest": "sha256:e003ca3479b14b5d3f94fdd0c8db7ffe20269aad29628e0d09f7f367cd2cc7f8" + }, + { + "mode": "100644", + "owner": "todo-model-packages-todo-domain-tests-todo-test-ts", + "path": "packages/todo/domain/tests/todo.test.ts", + "sourceDigest": "sha256:99e4f463a497275f7bf6dacbe0402dde1b054dbe8e0568256dccd6a6a5448514" + }, + { + "mode": "100644", + "owner": "package-surface-infrastructure-manifest", + "path": "packages/todo/infrastructure/package.json", + "sourceDigest": "sha256:5a28a70e873ee887a1819bafa2f11ae4d47cd44aaaac410d621493346d186889" + }, + { + "mode": "100644", + "owner": "package-surface-infrastructure-barrel", + "path": "packages/todo/infrastructure/src/index.ts", + "sourceDigest": "sha256:73bfa06f4aac3cfee6fad9425490d86dbfab70ec79a69d2559a41c93bd391e35" + }, + { + "mode": "100644", + "owner": "todo-integration-adapter-packages-todo-infrastructure-tests-todo-runtime-test-ts", + "path": "packages/todo/infrastructure/tests/todo-runtime.test.ts", + "sourceDigest": "sha256:8a305172097e942a07398ee5bfeec90e2a84a07ea5b97050138c8549dcf9949a" + }, + { + "mode": "100644", + "owner": "workspace-surface-pnpm-workspace-yaml", + "path": "pnpm-workspace.yaml", + "sourceDigest": "sha256:38153e8982dc83ad4f576c62dd12e80fd222668b7ebd29c9c89092a41bf10544" + }, + { + "mode": "100644", + "owner": "workspace-surface-tsconfig-build-json", + "path": "tsconfig.build.json", + "sourceDigest": "sha256:a12ff29d34e79ed58b9d9540572968f6f32721482f776500b2f2495810926138" + }, + { + "mode": "100644", + "owner": "workspace-surface-vitest-config-mts", + "path": "vitest.config.mts", + "sourceDigest": "sha256:3d6177cfd34114394a841bb24fdf661f2879b0b75cfe2d793ae414d2191081de" + } + ], + "provenanceDigest": "sha256:62a41dcb635db627d5531abb58266493cfb56367960b6175ff2908132fa0dea9", + "version": "effectify.app-builder-replay-provenance/1" + } +} diff --git a/examples/app-builder-todo/README.md b/examples/app-builder-todo/README.md new file mode 100644 index 00000000..1ef79a2b --- /dev/null +++ b/examples/app-builder-todo/README.md @@ -0,0 +1,55 @@ +# App Builder Todo Showcase + +This directory is the canonical Todo workspace produced by the installed public App Builder `generate` command. It is a committed inspection fixture with its own pnpm and Nx workspace; root pnpm, Nx, and formatter discovery intentionally exclude it. + +## Canonical provenance + +The exact request, replay provenance, output identities, file modes, and source digests are recorded in [`.effectify/app-builder/showcase.json`](.effectify/app-builder/showcase.json). The generated set is limited to the 18 paths named by `provenance.outputIdentities`; this README and the receipt are authored freeze metadata. + +Regeneration is allowed only after `Pr130Regeneration.requirePr130RegenerationEligibility` accepts the complete pinned Unit 1โ€“6 proof and the current frozen PR #130 head. + +## Public regeneration path + +From the repository root, build the public packages and run the isolated checker: + +```sh +pnpm install --frozen-lockfile +pnpm nx run @effectify/app-builder-cli:build --skipNxCache +output="$(mktemp -d)/app-builder-todo" +node tools/check-app-builder-showcase.mjs \ + --expected examples/app-builder-todo \ + --output "$output" +``` + +The checker packs the public contracts, generation, and CLI packages; installs those tarballs into an isolated driver; invokes its `node_modules/.bin/effectify-app-builder generate --events=jsonl`; and compares every output identity byte-for-byte and mode-for-mode. It never writes to this committed directory. If intentional canonical drift is reported, independently validate the gate and generated workspace before replacing only the receipt-listed generated paths. + +## Standalone verification + +In an isolated copy, materialize a lockfile and run the generated workspace through its package manager: + +```sh +pnpm install --lockfile-only +pnpm install --frozen-lockfile +pnpm exec nx graph --print +pnpm exec nx run-many \ + --targets=test,typecheck,build \ + --projects=@effectify/todo-domain,@effectify/todo-application,@effectify/todo-infrastructure,@effectify/todo-cli +``` + +The exact project graph is: + +- `@effectify/todo-domain` +- `@effectify/todo-application` +- `@effectify/todo-infrastructure` +- `@effectify/todo-cli` + +## Acceptance evidence + +| Boundary | Executable evidence | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Unit 7 eligibility | Built `Pr130Regeneration.requirePr130RegenerationEligibility` with complete pinned Unit 1โ€“6 identities and the frozen PR #130 head | +| Installed public generation | Packed registry-installed public CLI invocation with JSONL terminal evidence | +| Generated workspace | Exact four-project Nx graph; all project test, typecheck, and build targets | +| Runtime | Live Todo add, complete, remove, empty-list, and durable empty-state proof | +| Replay | Second installed public invocation writes zero paths and leaves all generated bytes and modes unchanged | +| Drift and exclusion | `tools/check-app-builder-showcase.mjs`, CLI showcase tests, root Nx/pnpm exclusion, format check, sync check, and `git diff --check` | diff --git a/examples/app-builder-todo/apps/todo-cli/package.json b/examples/app-builder-todo/apps/todo-cli/package.json new file mode 100644 index 00000000..b7d5d55d --- /dev/null +++ b/examples/app-builder-todo/apps/todo-cli/package.json @@ -0,0 +1,19 @@ +{ + "name": "@effectify/todo-cli", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:", + "@effectify/todo-infrastructure": "workspace:*", + "@effectify/todo-application": "workspace:*", + "@effectify/todo-domain": "workspace:*" + } +} diff --git a/packages/app-builder/generation/src/templates/todo/cli.ts b/examples/app-builder-todo/apps/todo-cli/src/index.ts similarity index 76% rename from packages/app-builder/generation/src/templates/todo/cli.ts rename to examples/app-builder-todo/apps/todo-cli/src/index.ts index 1e539c46..86efac4d 100644 --- a/packages/app-builder/generation/src/templates/todo/cli.ts +++ b/examples/app-builder-todo/apps/todo-cli/src/index.ts @@ -1,8 +1,8 @@ -export const cliTemplate = String.raw`import * as Effect from "effect/Effect" +import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" -import * as Application from "../../../packages/todo/application/src/index.js" -import * as Infrastructure from "../../../packages/todo/infrastructure/src/index.js" -import type { TodoEvent } from "../../../packages/todo/domain/src/index.js" +import * as Application from "@effectify/todo-application" +import type { TodoEvent } from "@effectify/todo-domain" +import * as Infrastructure from "@effectify/todo-infrastructure" export const createTestRuntime = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Effect.gen(function* () { const layer = Application.layer.pipe(Layer.provideMerge(Infrastructure.testLayer(input))) @@ -19,4 +19,3 @@ export const renderEvent = (event: TodoEvent): string => event._tag === "TodoAdded" ? "added:" + event.todo.id + ":" + event.todo.text : event._tag === "TodoCompleted" ? "completed:" + event.todo.id + ":" + event.todo.text : "removed:" + event.todo.id + ":" + event.todo.text -` diff --git a/examples/app-builder-todo/apps/todo-cli/tests/todo.test.ts b/examples/app-builder-todo/apps/todo-cli/tests/todo.test.ts new file mode 100644 index 00000000..87988b35 --- /dev/null +++ b/examples/app-builder-todo/apps/todo-cli/tests/todo.test.ts @@ -0,0 +1,9 @@ +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import { createTestRuntime } from "../src/index.js" + +it.effect("runs deterministic Todo behavior", () => Effect.gen(function* () { + const todo = yield* createTestRuntime({ ids: ["todo-1"], now: "2026-01-01T00:00:00.000Z" }) + expect(yield* todo.list()).toEqual([]) + expect((yield* todo.add("write tests")).id).toBe("todo-1") +})) diff --git a/examples/app-builder-todo/nx.json b/examples/app-builder-todo/nx.json new file mode 100644 index 00000000..d74ff27e --- /dev/null +++ b/examples/app-builder-todo/nx.json @@ -0,0 +1,21 @@ +{ + "defaultBase": "HEAD", + "plugins": [ + { + "plugin": "@nx/js/typescript", + "exclude": ["tsconfig.build.json"], + "options": { + "typecheck": { + "targetName": "typecheck" + } + } + }, + { + "plugin": "@nx/vitest", + "exclude": ["vitest.config.mts"], + "options": { + "testTargetName": "test" + } + } + ] +} diff --git a/examples/app-builder-todo/package.json b/examples/app-builder-todo/package.json new file mode 100644 index 00000000..143de178 --- /dev/null +++ b/examples/app-builder-todo/package.json @@ -0,0 +1,20 @@ +{ + "name": "@effectify/todo-workspace", + "packageManager": "pnpm@10.14.0", + "private": true, + "scripts": { + "build": "pnpm exec tsc -p tsconfig.build.json", + "test": "pnpm exec vitest run --config vitest.config.mts", + "typecheck": "pnpm exec tsc --noEmit -p tsconfig.build.json" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "@nx/js": "catalog:", + "@nx/vitest": "23.1.0", + "effect": "catalog:", + "nx": "23.1.0", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/app-builder-todo/packages/todo/application/package.json b/examples/app-builder-todo/packages/todo/application/package.json new file mode 100644 index 00000000..5a7a5656 --- /dev/null +++ b/examples/app-builder-todo/packages/todo/application/package.json @@ -0,0 +1,17 @@ +{ + "name": "@effectify/todo-application", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:", + "@effectify/todo-domain": "workspace:*" + } +} diff --git a/packages/app-builder/generation/src/templates/todo/application.ts b/examples/app-builder-todo/packages/todo/application/src/index.ts similarity index 95% rename from packages/app-builder/generation/src/templates/todo/application.ts rename to examples/app-builder-todo/packages/todo/application/src/index.ts index 5fdcc21f..8affca86 100644 --- a/packages/app-builder/generation/src/templates/todo/application.ts +++ b/examples/app-builder-todo/packages/todo/application/src/index.ts @@ -1,9 +1,9 @@ -export const applicationTemplate = String.raw`import * as Context from "effect/Context" +import * as Context from "effect/Context" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" import * as Option from "effect/Option" import * as Schema from "effect/Schema" -import { TodoEvent, TodoId, type Todo, type TodoEvent as Event, TodoAlreadyCompleted, TodoIdExhausted, TodoNotFound, TodoPersistenceError, TodoTextInvalid } from "../../domain/src/index.js" +import { TodoEvent, TodoId, type Todo, type TodoEvent as Event, TodoAlreadyCompleted, TodoIdExhausted, TodoNotFound, TodoPersistenceError, TodoTextInvalid } from "@effectify/todo-domain" export interface TodoRepositoryApi { readonly find: (id: TodoId) => Effect.Effect @@ -65,4 +65,3 @@ export const layer = Layer.effect(TodoApplication, Effect.gen(function* () { }) return TodoApplication.of({ add, complete, list: repository.list, remove }) })) -` diff --git a/examples/app-builder-todo/packages/todo/application/src/use-case.ts b/examples/app-builder-todo/packages/todo/application/src/use-case.ts new file mode 100644 index 00000000..8873faf7 --- /dev/null +++ b/examples/app-builder-todo/packages/todo/application/src/use-case.ts @@ -0,0 +1 @@ +export { TodoApplication } from "./index.js" diff --git a/examples/app-builder-todo/packages/todo/domain/package.json b/examples/app-builder-todo/packages/todo/domain/package.json new file mode 100644 index 00000000..412724d1 --- /dev/null +++ b/examples/app-builder-todo/packages/todo/domain/package.json @@ -0,0 +1,16 @@ +{ + "name": "@effectify/todo-domain", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:" + } +} diff --git a/examples/app-builder-todo/packages/todo/domain/src/events.ts b/examples/app-builder-todo/packages/todo/domain/src/events.ts new file mode 100644 index 00000000..baec8f7f --- /dev/null +++ b/examples/app-builder-todo/packages/todo/domain/src/events.ts @@ -0,0 +1 @@ +export type { TodoEvent } from "./index.js" diff --git a/packages/app-builder/generation/src/templates/todo/domain.ts b/examples/app-builder-todo/packages/todo/domain/src/index.ts similarity index 93% rename from packages/app-builder/generation/src/templates/todo/domain.ts rename to examples/app-builder-todo/packages/todo/domain/src/index.ts index b8a82bab..c7e6540b 100644 --- a/packages/app-builder/generation/src/templates/todo/domain.ts +++ b/examples/app-builder-todo/packages/todo/domain/src/index.ts @@ -1,4 +1,4 @@ -export const domainTemplate = String.raw`import * as Data from "effect/Data" +import * as Data from "effect/Data" import * as Schema from "effect/Schema" export const TodoId = Schema.NonEmptyString.pipe(Schema.brand("Effectify.TodoId")) @@ -26,4 +26,3 @@ export type TodoEvent = Data.TaggedEnum<{ TodoRemoved: { readonly todo: Todo } }> export const TodoEvent = Data.taggedEnum() -` diff --git a/examples/app-builder-todo/packages/todo/domain/tests/todo.test.ts b/examples/app-builder-todo/packages/todo/domain/tests/todo.test.ts new file mode 100644 index 00000000..b888c5ed --- /dev/null +++ b/examples/app-builder-todo/packages/todo/domain/tests/todo.test.ts @@ -0,0 +1,7 @@ +import { expect, it } from "vitest" +import * as Schema from "effect/Schema" +import { Todo } from "../src/index.js" + +it("decodes a Todo schema", () => { + expect(Schema.is(Todo)({ completed: false, createdAt: "2026-01-01T00:00:00.000Z", id: "todo-1", text: "write tests" })).toBe(true) +}) diff --git a/examples/app-builder-todo/packages/todo/infrastructure/package.json b/examples/app-builder-todo/packages/todo/infrastructure/package.json new file mode 100644 index 00000000..ad5818ab --- /dev/null +++ b/examples/app-builder-todo/packages/todo/infrastructure/package.json @@ -0,0 +1,18 @@ +{ + "name": "@effectify/todo-infrastructure", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:", + "@effectify/todo-application": "workspace:*", + "@effectify/todo-domain": "workspace:*" + } +} diff --git a/packages/app-builder/generation/src/templates/todo/infrastructure.ts b/examples/app-builder-todo/packages/todo/infrastructure/src/index.ts similarity index 94% rename from packages/app-builder/generation/src/templates/todo/infrastructure.ts rename to examples/app-builder-todo/packages/todo/infrastructure/src/index.ts index 55ae6649..b1b004f6 100644 --- a/packages/app-builder/generation/src/templates/todo/infrastructure.ts +++ b/examples/app-builder-todo/packages/todo/infrastructure/src/index.ts @@ -1,4 +1,4 @@ -export const infrastructureTemplate = String.raw`import { access, mkdir, readFile, writeFile } from "node:fs/promises" +import { access, mkdir, readFile, writeFile } from "node:fs/promises" import { randomUUID } from "node:crypto" import { dirname } from "node:path" import * as Context from "effect/Context" @@ -8,8 +8,8 @@ import * as PubSub from "effect/PubSub" import * as Ref from "effect/Ref" import * as Stream from "effect/Stream" import * as Schema from "effect/Schema" -import * as Application from "../../application/src/index.js" -import { Todo, TodoId, TodoIdExhausted, TodoPersistenceError, type Todo as TodoModel, type TodoEvent as Event } from "../../domain/src/index.js" +import * as Application from "@effectify/todo-application" +import { Todo, TodoId, TodoIdExhausted, TodoPersistenceError, type Todo as TodoModel, type TodoEvent as Event } from "@effectify/todo-domain" export interface TodoTestProbeApi { readonly events: () => Effect.Effect> } export class TodoTestProbe extends Context.Service()("@effectify/todo/TodoTestProbe") {} @@ -89,4 +89,3 @@ export const liveLayer = (path: string) => Layer.effectContext(Effect.gen(functi Context.add(Application.TodoEvents, eventServices.service), ) })) -` diff --git a/examples/app-builder-todo/packages/todo/infrastructure/tests/todo-runtime.test.ts b/examples/app-builder-todo/packages/todo/infrastructure/tests/todo-runtime.test.ts new file mode 100644 index 00000000..3214062e --- /dev/null +++ b/examples/app-builder-todo/packages/todo/infrastructure/tests/todo-runtime.test.ts @@ -0,0 +1,13 @@ +import { expect, it } from "@effect/vitest" +import * as Application from "@effectify/todo-application" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import { TodoTestProbe, testLayer } from "../src/index.js" + +it.effect("publishes ordered Test Layer events after writes", () => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provideMerge(testLayer({ ids: ["todo-1"], now: "2026-01-01T00:00:00.000Z" }))) + const services = yield* Effect.all({ app: Application.TodoApplication, probe: TodoTestProbe }).pipe(Effect.provide(layer)) + const added = yield* services.app.add("write tests") + yield* services.app.complete(added.id) + expect(yield* services.probe.events()).toEqual([{ _tag: "TodoAdded", todo: added }, { _tag: "TodoCompleted", todo: { ...added, completed: true } }]) +})) diff --git a/examples/app-builder-todo/pnpm-workspace.yaml b/examples/app-builder-todo/pnpm-workspace.yaml new file mode 100644 index 00000000..b292c5d2 --- /dev/null +++ b/examples/app-builder-todo/pnpm-workspace.yaml @@ -0,0 +1,11 @@ +packages: + - apps/* + - packages/*/* + +catalog: + "@effect/vitest": 4.0.0-beta.102 + "@nx/js": 23.1.0 + "@types/node": 20.19.25 + effect: 4.0.0-beta.102 + typescript: 6.0.3 + vitest: 4.1.10 diff --git a/examples/app-builder-todo/tsconfig.build.json b/examples/app-builder-todo/tsconfig.build.json new file mode 100644 index 00000000..1ab3c3a2 --- /dev/null +++ b/examples/app-builder-todo/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [ + "node" + ] + }, + "include": [ + "apps/**/src/**/*.ts", + "packages/**/src/**/*.ts" + ] +} diff --git a/examples/app-builder-todo/vitest.config.mts b/examples/app-builder-todo/vitest.config.mts new file mode 100644 index 00000000..679541f2 --- /dev/null +++ b/examples/app-builder-todo/vitest.config.mts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + include: ["apps/**/tests/**/*.test.ts", "packages/**/tests/**/*.test.ts"], + watch: false, + }, +}) diff --git a/nx.json b/nx.json index 297cc2f1..aabdaf9a 100644 --- a/nx.json +++ b/nx.json @@ -20,6 +20,7 @@ "plugins": [ { "plugin": "@nx/js/typescript", + "exclude": ["examples/app-builder-todo/**/*"], "options": { "compiler": "tsgo", "typecheck": { @@ -70,7 +71,12 @@ "options": { "testTargetName": "test" }, - "exclude": ["apps/solid-app-start/**/*", "apps/react-remix-example/**/*", "apps/minimal-example/**/*"] + "exclude": [ + "apps/solid-app-start/**/*", + "apps/react-remix-example/**/*", + "apps/minimal-example/**/*", + "examples/app-builder-todo/**/*" + ] }, { "plugin": "@nx/cypress/plugin", diff --git a/openspec/changes/app-builder-golden-monorepo/tasks.md b/openspec/changes/app-builder-golden-monorepo/tasks.md index 3782a7d7..5025728d 100644 --- a/openspec/changes/app-builder-golden-monorepo/tasks.md +++ b/openspec/changes/app-builder-golden-monorepo/tasks.md @@ -58,15 +58,15 @@ R01โ€“R17/S01โ€“S32 are ordered below; T1/T2 are mandatory RED tests. PR 07 defi ## Phase 6: Replay (PR 06) -- [ ] 6.1 RED `packages/app-builder/generation/tests/replay.test.ts`: R12/R13 S21โ€“S24; semantic dependencies and changed-output pre-write failure. -- [ ] 6.2 GREEN/REFACTOR `packages/app-builder/generation/src/{cjson,provenance,replay}.ts`: pinned frozen replay, canonical digests, zero-diff evidence. +- [x] 6.1 RED `packages/app-builder/generation/tests/replay.test.ts`: R12/R13 S21โ€“S24; semantic dependencies and changed-output pre-write failure. +- [x] 6.2 GREEN/REFACTOR `packages/app-builder/generation/src/{cjson,provenance,replay}.ts`: pinned frozen replay, canonical digests, zero-diff evidence. ## Phase 7: Nested E2E (PR 07) -- [ ] 7.1 RED `packages/app-builder/e2e/tests/golden.e2e.test.ts`: R14/R15 S25โ€“S28; failed/interrupted cleanup and root exclusion. -- [ ] 7.2 GREEN/REFACTOR `packages/app-builder/e2e/**`: OS-temp distribution, isolated registry/Nx/pnpm, nested graph/test/typecheck/build/CRUD/regeneration. +- [x] 7.1 RED `packages/app-builder/e2e/tests/golden.e2e.test.ts`: R14/R15 S25โ€“S28; failed/interrupted cleanup and root exclusion. +- [x] 7.2 GREEN/REFACTOR `packages/app-builder/e2e/**`: OS-temp distribution, isolated registry/Nx/pnpm, nested graph/test/typecheck/build/CRUD/regeneration. ## Phase 8: Showcase and Closure (PR 08) -- [ ] 8.1 RED `examples/app-builder-todo`, `.github/workflows/ci.yml`: R16/R17 S29โ€“S32; drift fails without rewrite. -- [ ] 8.2 GREEN/REFACTOR `examples/app-builder-todo/**`, `.github/workflows/ci.yml`: public-command exclusion; final R01โ€“R17/S01โ€“S32, T, typecheck, build, clean-diff matrix. +- [x] 8.1 RED `examples/app-builder-todo`, `.github/workflows/ci.yml`: R16/R17 S29โ€“S32; drift fails without rewrite. +- [x] 8.2 GREEN/REFACTOR `examples/app-builder-todo/**`, `.github/workflows/ci.yml`: public-command exclusion; final R01โ€“R17/S01โ€“S32, T, typecheck, build, clean-diff matrix. 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/commands.ts b/packages/app-builder/cli/src/commands.ts index 3c953dc1..706134c4 100644 --- a/packages/app-builder/cli/src/commands.ts +++ b/packages/app-builder/cli/src/commands.ts @@ -1,6 +1,16 @@ import * as Effect from "effect/Effect" -import { Catalog, Planner } from "@effectify/app-builder-generation" -import { type CliFailure, type CliRequest, type CliTerminal, InputError, success, unavailable } from "./protocol.js" +import { Catalog, Planner, Replay } from "@effectify/app-builder-generation" +import { generateTodo } from "./generate.js" +import { + ConflictError, + type CliFailure, + type CliRequest, + type CliTerminal, + HostError, + InputError, + success, + unavailable, +} from "./protocol.js" export interface CommandDispatcher { readonly dispatch: (request: CliRequest) => Effect.Effect @@ -28,11 +38,25 @@ export const commandDispatcher: CommandDispatcher = { }), ) case "generate": + return generateTodo(request.payload).pipe( + Effect.map((result) => success(request.command, result)), + Effect.mapError((error) => + error._tag === "GenerateInputError" + ? new InputError({ reason: error.reason }) + : error._tag === "GenerateConflictError" + ? new ConflictError({ reason: `generated output conflicts at ${error.path}` }) + : new HostError({ reason: error.reason }), + ), + ) case "verify": - case "replay": case "explain": case "doctor": return Effect.succeed(unavailable(request.command)) + case "replay": + return Replay.validateReplayPayload(request.payload).pipe( + Effect.map((result) => success(request.command, result)), + Effect.mapError(() => new InputError({ reason: "replay payload does not match trusted provenance" })), + ) } }, } diff --git a/packages/app-builder/cli/src/generate.ts b/packages/app-builder/cli/src/generate.ts new file mode 100644 index 00000000..78029f42 --- /dev/null +++ b/packages/app-builder/cli/src/generate.ts @@ -0,0 +1,211 @@ +import { lstat, mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { dirname, isAbsolute, relative, resolve } from "node:path" +import { Planner, Replay, TodoPreset, type ReplayProvenance } from "@effectify/app-builder-generation" +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" + +const GenerateRequest = Schema.Struct({ + intent: Schema.Unknown, + workspace: Schema.String, +}) + +type GenerateRequest = typeof GenerateRequest.Type + +interface PendingOutput { + readonly content: string + readonly path: string + readonly target: string +} + +export interface GenerateResult { + readonly generatedRoots: ReadonlyArray + readonly provenance: ReplayProvenance + readonly workspace: string + readonly writtenPaths: ReadonlyArray +} + +export class GenerateInputError extends Data.TaggedError("GenerateInputError")<{ + readonly reason: string +}> {} + +export class GenerateConflictError extends Data.TaggedError("GenerateConflictError")<{ + readonly path: string +}> {} + +export class GenerateHostError extends Data.TaggedError("GenerateHostError")<{ + readonly reason: string +}> {} + +type GenerateError = GenerateConflictError | GenerateHostError | GenerateInputError + +const safeWorkspaceSegment = (segment: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment) + +const safeOutputSegment = (segment: string): boolean => + segment !== "." && segment !== ".." && /^[A-Za-z0-9.][A-Za-z0-9._-]*$/.test(segment) + +const safeSegments = (path: string, isSafeSegment: (segment: string) => boolean): ReadonlyArray | undefined => { + if (path.length === 0 || isAbsolute(path) || path.includes("\\")) return undefined + const segments = path.split("/") + return segments.every(isSafeSegment) ? segments : undefined +} + +const hasCode = (error: unknown, code: string): boolean => + typeof error === "object" && error !== null && Reflect.get(error, "code") === code + +const existingEntry = async (path: string) => { + try { + return await lstat(path) + } catch (error) { + if (hasCode(error, "ENOENT")) return undefined + throw error + } +} + +const workspacePath = (workspace: string): Effect.Effect => { + const segments = safeSegments(workspace, safeWorkspaceSegment) + if (segments === undefined) { + return Effect.fail(new GenerateInputError({ reason: "workspace must be a safe relative path" })) + } + + const root = resolve(process.cwd()) + const target = resolve(root, ...segments) + const pathRelative = relative(root, target) + return pathRelative.length === 0 || pathRelative === ".." || pathRelative.startsWith("../") + ? Effect.fail(new GenerateInputError({ reason: "workspace must stay below the current directory" })) + : Effect.succeed(target) +} + +const outputPath = (workspace: string, path: string, roots: ReadonlyArray): string => { + const segments = safeSegments(path, safeOutputSegment) + const isOwned = TodoPreset.isTodoTopologyPath(path, roots) + if (segments === undefined || !isOwned) { + throw new GenerateHostError({ reason: "generated topology contains an unsafe output path" }) + } + + const target = resolve(workspace, ...segments) + const pathRelative = relative(workspace, target) + if (pathRelative.length === 0 || pathRelative === ".." || pathRelative.startsWith("../")) { + throw new GenerateHostError({ reason: "generated topology escapes the requested workspace" }) + } + return target +} + +const requireSafeDirectories = async (workspace: string, output: PendingOutput): Promise => { + const workspaceEntry = await existingEntry(workspace) + if (workspaceEntry !== undefined && (!workspaceEntry.isDirectory() || workspaceEntry.isSymbolicLink())) { + throw new GenerateConflictError({ path: workspace }) + } + + const relativeDirectory = relative(workspace, dirname(output.target)) + let current = workspace + for (const segment of relativeDirectory.length === 0 ? [] : relativeDirectory.split("/")) { + current = resolve(current, segment) + const entry = await existingEntry(current) + if (entry !== undefined && (!entry.isDirectory() || entry.isSymbolicLink())) { + throw new GenerateConflictError({ path: current }) + } + } +} + +const prevalidate = async ( + workspace: string, + topology: TodoPreset.TodoTopology, +): Promise> => { + const paths = new Set() + const pending: Array = [] + + for (const file of [...topology.files].sort((left, right) => left.path.localeCompare(right.path))) { + if (paths.has(file.path)) throw new GenerateConflictError({ path: file.path }) + paths.add(file.path) + const target = outputPath(workspace, file.path, topology.roots) + const output = { content: file.content, path: file.path, target } + await requireSafeDirectories(workspace, output) + const existing = await existingEntry(target) + if (existing === undefined) { + pending.push(output) + continue + } + if (!existing.isFile() || existing.isSymbolicLink() || (await readFile(target, "utf8")) !== file.content) { + throw new GenerateConflictError({ path: file.path }) + } + } + + return Object.freeze(pending) +} + +const writeOutputs = async (outputs: ReadonlyArray): Promise> => { + const written: Array = [] + try { + for (const output of outputs) { + await mkdir(dirname(output.target), { recursive: true }) + await writeFile(output.target, output.content, { encoding: "utf8", flag: "wx" }) + written.push(output) + } + } catch (error) { + await Promise.all(written.map((output) => rm(output.target, { force: true }))) + if (hasCode(error, "EEXIST")) throw new GenerateConflictError({ path: "concurrent-output" }) + throw error + } + return Object.freeze(written.map((output) => output.path)) +} + +const candidate = (plan: Planner.TodoPlan, topology: TodoPreset.TodoTopology) => ({ + blocks: topology.files.map(({ owner, path }) => ({ owner, path })), + catalog: { capabilities: plan.orderedCapabilities, version: plan.catalogVersion }, + dependencies: [], + intent: plan.intent, + outputs: topology.files.map(({ content, owner, path }) => ({ content, mode: "100644", owner, path })), + pins: { + effect: "4.0.0", + frozenInstall: true, + nx: "23.1.0", + packageManager: "pnpm@10.14.0", + plugins: plan.selectedPlugins.map(({ packageName, packageVersion }) => ({ + name: packageName, + version: packageVersion, + })), + }, + plan, +}) + +/** Generates the finite Todo topology through a prevalidated, one-way filesystem boundary. */ +export const generateTodo = (payload: unknown): Effect.Effect => + Effect.gen(function* () { + const input: GenerateRequest = yield* Schema.decodeUnknownEffect(GenerateRequest, { onExcessProperty: "error" })( + payload, + ).pipe( + Effect.mapError(() => new GenerateInputError({ reason: "generate payload must include intent and workspace" })), + ) + const workspace = yield* workspacePath(input.workspace) + const plan = yield* Planner.planTodo(input.intent).pipe( + Effect.mapError(() => new GenerateInputError({ reason: "generate intent is not valid" })), + ) + const topology = yield* TodoPreset.createTodoTopology(plan).pipe( + Effect.mapError(() => new GenerateInputError({ reason: "generate intent is missing required capabilities" })), + ) + const provenance = yield* Replay.captureReplayProvenance(candidate(plan, topology)).pipe( + Effect.mapError(() => new GenerateHostError({ reason: "unable to capture generation provenance" })), + ) + const pending = yield* Effect.tryPromise({ + try: () => prevalidate(workspace, topology), + catch: (error) => + error instanceof GenerateConflictError || error instanceof GenerateHostError + ? error + : new GenerateHostError({ reason: "unable to prevalidate generated output" }), + }) + const writtenPaths = yield* Effect.tryPromise({ + try: () => writeOutputs(pending), + catch: (error) => + error instanceof GenerateConflictError + ? error + : new GenerateHostError({ reason: "unable to write generated output" }), + }) + + return Object.freeze({ + generatedRoots: Object.freeze([...topology.roots]), + provenance, + workspace: input.workspace, + writtenPaths, + }) + }) diff --git a/packages/app-builder/cli/src/index.ts b/packages/app-builder/cli/src/index.ts index fa6e2676..d2586080 100644 --- a/packages/app-builder/cli/src/index.ts +++ b/packages/app-builder/cli/src/index.ts @@ -1,4 +1,20 @@ +import { + composeCatalog, + type AtomicGenerator, + type FiniteCatalog, + type GeneratorId, +} from "@effectify/app-builder-generation" export { renderTodoPlan } from "./plan.js" + +export const composeGeneration = < + const Generators extends readonly [AtomicGenerator, ...AtomicGenerator[]], +>(options: { + readonly catalog: FiniteCatalog + readonly context: unknown + readonly input: Generators[number] extends AtomicGenerator ? Input : never + readonly selected: ReadonlyArray +}) => composeCatalog(options) +export { generateTodo } from "./generate.js" export { runCli, runNodeCli } from "./main.js" export { CliCommands, diff --git a/packages/app-builder/cli/src/main.ts b/packages/app-builder/cli/src/main.ts index f1f0cc6a..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, @@ -64,7 +68,11 @@ const parseJson = (value: string): Effect.Effect => catch: () => new InputError({ reason: "input must be canonical JSON" }), }) -const exitCode = (failure: CliFailure): number => (failure._tag === "InputError" ? 2 : 7) +const exitCode = (failure: CliFailure): number => + failure._tag === "InputError" ? 2 : failure._tag === "ConflictError" ? 4 : 7 + +const failureKind = (failure: CliFailure): "conflict" | "host" | "input" => + failure._tag === "InputError" ? "input" : failure._tag === "ConflictError" ? "conflict" : "host" const terminalEnvelope = (terminal: CliTerminal | { readonly _tag: "Failure"; readonly error: CliFailure }) => ({ version: "effectify.app-builder-cli-terminal/1", @@ -80,7 +88,7 @@ const writeTerminal = ( .pipe(Effect.mapError(() => new HostError({ reason: "unable to write stdout" }))) const writeFailure = (runtime: CliRuntime, failure: CliFailure) => - runtime.writeStderr(`${failure._tag === "InputError" ? "input" : "host"}: ${failure.reason}\n`).pipe( + runtime.writeStderr(`${failureKind(failure)}: ${failure.reason}\n`).pipe( Effect.mapError(() => new HostError({ reason: "unable to write stderr" })), Effect.andThen(() => writeTerminal(runtime, { _tag: "Failure", error: failure })), ) @@ -167,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/cli/src/protocol.ts b/packages/app-builder/cli/src/protocol.ts index 9f493304..2185b238 100644 --- a/packages/app-builder/cli/src/protocol.ts +++ b/packages/app-builder/cli/src/protocol.ts @@ -23,7 +23,7 @@ export const CliUnavailableSchema = Schema.Struct({ command: CliCommandSchema, }) export const CliErrorSchema = Schema.Struct({ - _tag: Schema.Literals(["InputError", "HostError"]), + _tag: Schema.Literals(["InputError", "ConflictError", "HostError"]), reason: Schema.String, }) export const CliTerminalEnvelopeSchema = Schema.Struct({ @@ -54,11 +54,15 @@ export class InputError extends Data.TaggedError("InputError")<{ readonly reason: string }> {} +export class ConflictError extends Data.TaggedError("ConflictError")<{ + readonly reason: string +}> {} + export class HostError extends Data.TaggedError("HostError")<{ readonly reason: string }> {} -export type CliFailure = InputError | HostError +export type CliFailure = ConflictError | InputError | HostError export const isCliCommand = (value: string): value is CliCommand => value === "catalog" || diff --git a/packages/app-builder/cli/tests/protocol.test.ts b/packages/app-builder/cli/tests/protocol.test.ts index b65f274f..a9ed3992 100644 --- a/packages/app-builder/cli/tests/protocol.test.ts +++ b/packages/app-builder/cli/tests/protocol.test.ts @@ -1,5 +1,8 @@ import { expect, it } from "vitest" +import { mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { join } from "node:path" import * as Effect from "effect/Effect" +import { surfaceRequest } from "../../generation/tests/surface-request.js" interface CommandDispatcher { readonly dispatch: (request: unknown) => Effect.Effect @@ -21,6 +24,13 @@ interface MainModule { ) => Effect.Effect } +interface ReplayModule { + readonly captureReplayProvenance: (candidate: unknown) => Effect.Effect +} + +type PublicModule = typeof import("../src/index.js") +type GenerationModule = typeof import("@effectify/app-builder-generation") + const request = { version: "effectify.app-builder-cli-request/1", command: "plan", @@ -31,8 +41,42 @@ const request = { }, } +const generateRequest = (workspace: string) => ({ + ...request, + command: "generate" as const, + payload: { + intent: request.payload, + workspace, + }, +}) +const customGenerateRequest = (workspace: string) => ({ + ...generateRequest(workspace), + payload: { + ...generateRequest(workspace).payload, + intent: { + ...request.payload, + 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 main = () => Effect.promise(() => import(new URL("../src/main.js", import.meta.url).href)) +const replay = () => + Effect.promise(() => import(new URL("../../generation/src/replay.js", import.meta.url).href)) + +const publicSurface = () => + Effect.all({ + Cli: Effect.promise(() => import(new URL("../src/index.js", import.meta.url).href)), + Generation: Effect.promise(() => import("@effectify/app-builder-generation")), + }) + const invoke = ( args: ReadonlyArray, stdin: string, @@ -99,6 +143,22 @@ effect("S16 and S18 execute the trusted plan command from stdin or an explicit J }), ) +effect("generic CLI planning behaviorally composes actual surface catalogs", () => + Effect.gen(function* () { + const { Cli, Generation } = yield* publicSurface() + for (const [scope, workspace] of [ + ["@acme", "task-workspace"], + ["@globex", "console"], + ]) { + const options = surfaceRequest(Generation, scope, workspace) + const direct = yield* Generation.composeCatalog(options) + const adapter = yield* Cli.composeGeneration(options) + + expect(adapter).toEqual(direct) + } + }), +) + effect("S19 emits JSON Lines only when selected and always ends with one terminal envelope", () => Effect.gen(function* () { const result = yield* invoke(["plan", "--events=jsonl"], JSON.stringify(request)) @@ -116,17 +176,197 @@ effect("S19 emits JSON Lines only when selected and always ends with one termina }), ) -effect("S16 accepts only catalog and the five closed deferred command names", () => +effect("R16 prerequisite generates deterministic consumer output through the public JSONL command", () => { + const workspace = "tests/public-generate-workspace" + const workspacePath = join(process.cwd(), workspace) + + return Effect.gen(function* () { + yield* Effect.promise(() => rm(workspacePath, { force: true, recursive: true })) + const input = JSON.stringify(customGenerateRequest(workspace)) + + return yield* Effect.gen(function* () { + const first = yield* invoke(["generate", "--events=jsonl"], input) + const firstFrames = first.stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + const firstTerminal = firstFrames[1]?.terminal + + expect(first.stderr).toBe("") + expect(first.exit).toBe(0) + expect(firstFrames).toHaveLength(2) + expect(firstFrames[0]).toMatchObject({ + version: "effectify.app-builder-cli-event/1", + _tag: "Event", + command: "generate", + }) + expect(firstTerminal).toMatchObject({ + _tag: "Success", + command: "generate", + result: { + provenance: { + version: "effectify.app-builder-replay-provenance/1", + }, + }, + }) + expect(firstTerminal.result.writtenPaths).not.toEqual([]) + expect( + yield* Effect.promise(() => readFile(join(workspacePath, "apps/admin-console/src/index.ts"), "utf8")), + ).toContain("createLiveRuntime") + + const { Generation } = yield* publicSurface() + const direct = yield* Generation.TodoPreset.createTodoTopology( + yield* Generation.Planner.planTodo(customGenerateRequest(workspace).payload.intent), + ) + const rootFiles = direct.files.filter((file) => + Generation.TodoGeneration.WorkspaceRootFiles.some((path) => path === file.path), + ) + + expect(rootFiles).toHaveLength(5) + expect(firstTerminal.result.writtenPaths).toEqual(expect.arrayContaining(rootFiles.map((file) => file.path))) + for (const file of rootFiles) { + expect(yield* Effect.promise(() => readFile(join(workspacePath, file.path), "utf8"))).toBe(file.content) + } + + const second = yield* invoke(["generate"], input) + const secondTerminal = JSON.parse(second.stdout).terminal + + expect(second.exit).toBe(0) + expect(second.stderr).toBe("") + expect(secondTerminal).toMatchObject({ _tag: "Success", command: "generate" }) + expect(secondTerminal.result.writtenPaths).toEqual([]) + expect(secondTerminal.result.provenance).toEqual(firstTerminal.result.provenance) + }).pipe(Effect.ensuring(Effect.promise(() => rm(workspacePath, { force: true, recursive: true })))) + }) +}) + +effect("R03 and R04 reject a conflicting generated target before writing any other output", () => { + const workspace = "tests/public-generate-conflict" + const workspacePath = join(process.cwd(), workspace) + const conflictingPath = join(workspacePath, "packages/todo/domain/package.json") + const userBytes = '{"name":"user-authored-domain"}\n' + + return Effect.gen(function* () { + yield* Effect.promise(() => rm(workspacePath, { force: true, recursive: true })) + yield* Effect.promise(async () => { + await mkdir(join(workspacePath, "packages/todo/domain"), { recursive: true }) + await writeFile(conflictingPath, userBytes) + }) + + return yield* Effect.gen(function* () { + const result = yield* invoke(["generate"], JSON.stringify(generateRequest(workspace))) + + expect(result.exit).toBe(4) + expect(result.stderr).toContain("conflict:") + expect(result.stdout.trim().split("\n")).toHaveLength(1) + expect(JSON.parse(result.stdout)).toMatchObject({ + terminal: { _tag: "Failure", error: { _tag: "ConflictError" } }, + }) + expect(yield* Effect.promise(() => readFile(conflictingPath, "utf8"))).toBe(userBytes) + expect( + yield* Effect.promise(() => + readFile(join(workspacePath, "apps/todo-cli/package.json"), "utf8") + .then(() => true) + .catch(() => false), + ), + ).toBe(false) + }).pipe(Effect.ensuring(Effect.promise(() => rm(workspacePath, { force: true, recursive: true })))) + }) +}) + +effect("R09 and R11 reject unsafe generate workspaces and injected automation before mutation", () => { + const injectedWorkspace = join(process.cwd(), "tests/injected-automation") + + return Effect.gen(function* () { + yield* Effect.promise(() => rm(injectedWorkspace, { force: true, recursive: true })) + + return yield* Effect.gen(function* () { + for (const payload of [ + generateRequest("../unsafe-workspace"), + { + ...generateRequest("tests/injected-automation"), + payload: { + intent: { ...request.payload, automation: { execute: "rm -rf /", tool: "mcp" } }, + workspace: "tests/injected-automation", + }, + }, + ]) { + const result = yield* invoke(["generate"], JSON.stringify(payload)) + + expect(result.exit).toBe(2) + expect(result.stderr).toContain("input:") + expect(JSON.parse(result.stdout)).toMatchObject({ + terminal: { _tag: "Failure", error: { _tag: "InputError" } }, + }) + } + expect( + yield* Effect.promise(() => + readFile(join(injectedWorkspace, "apps/todo-cli/package.json"), "utf8") + .then(() => true) + .catch(() => false), + ), + ).toBe(false) + }).pipe(Effect.ensuring(Effect.promise(() => rm(injectedWorkspace, { force: true, recursive: true })))) + }) +}) + +effect("S16 accepts catalog, binds replay to trusted evidence, and keeps three later commands closed", () => Effect.gen(function* () { const catalog = yield* invoke(["catalog"], JSON.stringify({ ...request, command: "catalog", payload: {} })) expect(catalog.exit).toBe(0) expect(JSON.parse(catalog.stdout)).toMatchObject({ terminal: { _tag: "Success", command: "catalog" } }) - for (const command of ["generate", "verify", "replay", "explain", "doctor"] as const) { + for (const command of ["verify", "explain", "doctor"] as const) { const result = yield* invoke([command], JSON.stringify({ ...request, command, payload: {} })) expect(result.exit).toBe(0) expect(JSON.parse(result.stdout)).toMatchObject({ terminal: { _tag: "NotAvailable", command } }) } + + const Replay = yield* replay() + const candidate = { + blocks: ["model"], + catalog: { version: "effectify.todo-catalog/1" }, + dependencies: [ + { + importer: "workspace", + integrity: "sha512-effect", + name: "effect", + peers: {}, + version: "4.0.0", + }, + ], + intent: request.payload, + outputs: [ + { + content: "export const generated = true\n", + mode: "100644", + owner: "@effectify/app-builder/model/1", + path: "packages/todo/domain/src/generated.ts", + }, + ], + pins: { + effect: "4.0.0", + frozenInstall: true, + nx: "23.1.0", + packageManager: "pnpm@10.14.0", + plugins: [{ name: "@effectify/app-builder-generation", version: "0.0.0" }], + }, + plan: request.payload, + } + const provenance = yield* Replay.captureReplayProvenance(candidate) + const replayResult = yield* invoke( + ["replay"], + JSON.stringify({ + ...request, + command: "replay", + payload: { candidate, provenance, workspaceOutputs: candidate.outputs }, + }), + ) + + expect(replayResult.exit).toBe(0) + expect(JSON.parse(replayResult.stdout)).toMatchObject({ + terminal: { _tag: "Success", command: "replay", result: { diffPaths: [], zeroDiff: true } }, + }) }), ) diff --git a/packages/app-builder/cli/tests/showcase.test.ts b/packages/app-builder/cli/tests/showcase.test.ts new file mode 100644 index 00000000..d3ccbdca --- /dev/null +++ b/packages/app-builder/cli/tests/showcase.test.ts @@ -0,0 +1,111 @@ +import { createHash } from "node:crypto" +import { execFile } from "node:child_process" +import { readFile, readdir, stat } from "node:fs/promises" +import { join, relative, resolve } from "node:path" +import { promisify } from "node:util" +import { expect, it } from "vitest" + +const exec = promisify(execFile) +const repositoryRoot = resolve(import.meta.dirname, "../../../../") +const showcaseRoot = join(repositoryRoot, "examples/app-builder-todo") +const receiptPath = join(showcaseRoot, ".effectify/app-builder/showcase.json") + +const walk = async (root: string, current = root): Promise> => + ( + await Promise.all( + ( + await readdir(current, { withFileTypes: true }) + ).map((entry) => { + const absolute = join(current, entry.name) + return entry.isDirectory() ? walk(root, absolute) : Promise.resolve([relative(root, absolute)]) + }), + ) + ) + .flat() + .sort() + +const run = async (args: ReadonlyArray) => + exec("pnpm", args, { + cwd: repositoryRoot, + env: { ...process.env, NX_DAEMON: "false" }, + maxBuffer: 16 * 1024 * 1024, + }) + +it("freezes every canonical installed-public-CLI output identity byte-for-byte and mode-for-mode", async () => { + const receipt = JSON.parse(await readFile(receiptPath, "utf8")) + const identities = receipt.provenance.outputIdentities as ReadonlyArray<{ + readonly mode: string + readonly path: string + readonly sourceDigest: string + }> + const paths = identities.map(({ path }) => path).sort() + const authored = [".effectify/app-builder/showcase.json", "README.md"] + const files = await walk(showcaseRoot) + + expect(receipt).toMatchObject({ + command: "generate", + request: { + command: "generate", + payload: { + intent: { + capabilities: ["todo.events"], + preset: "todo", + version: "effectify.creation-intent/1", + }, + workspace: "app-builder-todo", + }, + version: "effectify.app-builder-cli-request/1", + }, + version: "effectify.app-builder-showcase/1", + }) + expect(paths).toHaveLength(18) + expect(paths).toEqual( + expect.arrayContaining([ + "nx.json", + "package.json", + "pnpm-workspace.yaml", + "tsconfig.build.json", + "vitest.config.mts", + ]), + ) + expect(files).toEqual([...authored, ...paths].sort()) + + for (const identity of identities) { + const target = join(showcaseRoot, identity.path) + const bytes = await readFile(target) + expect(`sha256:${createHash("sha256").update(bytes).digest("hex")}`, identity.path).toBe(identity.sourceDigest) + expect((0o100000 | ((await stat(target)).mode & 0o777)).toString(8), identity.path).toBe(identity.mode) + } +}) + +it("keeps the standalone showcase outside root package, Nx, and formatter discovery", async () => { + const [readme, pnpmWorkspace, nxConfig, nxIgnore, formatConfig, projects, packages] = await Promise.all([ + readFile(join(showcaseRoot, "README.md"), "utf8"), + readFile(join(repositoryRoot, "pnpm-workspace.yaml"), "utf8"), + readFile(join(repositoryRoot, "nx.json"), "utf8"), + readFile(join(repositoryRoot, ".nxignore"), "utf8"), + readFile(join(repositoryRoot, ".oxfmtrc.json"), "utf8"), + run(["nx", "show", "projects", "--json"]), + run(["-r", "list", "--depth", "-1", "--json"]), + ]) + const config = JSON.parse(nxConfig) + const projectNames = JSON.parse(projects.stdout) + const packageNames = JSON.parse(packages.stdout).flatMap((entry: { readonly name?: string }) => + entry.name === undefined ? [] : [entry.name], + ) + + expect(readme).toContain("installed public App Builder") + expect(readme).toContain("node_modules/.bin/effectify-app-builder") + expect(pnpmWorkspace).toContain('"!examples/**"') + expect(nxIgnore).toContain("examples/app-builder-todo/**") + expect(JSON.parse(formatConfig).ignorePatterns).toContain("examples/app-builder-todo/**") + expect( + config.plugins + .filter((plugin: { readonly plugin: string }) => ["@nx/js/typescript", "@nx/vitest"].includes(plugin.plugin)) + .every((plugin: { readonly exclude?: ReadonlyArray }) => + plugin.exclude?.includes("examples/app-builder-todo/**/*"), + ), + ).toBe(true) + expect(projectNames.filter((name: string) => name.startsWith("@effectify/todo-"))).toEqual([]) + expect(packageNames).not.toContain("@effectify/todo-workspace") +}) diff --git a/packages/app-builder/cli/vitest.config.mts b/packages/app-builder/cli/vitest.config.mts index 3d7b43b9..d222fb95 100644 --- a/packages/app-builder/cli/vitest.config.mts +++ b/packages/app-builder/cli/vitest.config.mts @@ -6,6 +6,7 @@ export default defineConfig({ cacheDir: "../../../node_modules/.vite/packages/app-builder/cli", 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)), }, }, diff --git a/packages/app-builder/e2e/package.json b/packages/app-builder/e2e/package.json new file mode 100644 index 00000000..d1bd27ca --- /dev/null +++ b/packages/app-builder/e2e/package.json @@ -0,0 +1,16 @@ +{ + "name": "@effectify/app-builder-e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "@effectify/app-builder-generation": "workspace:*", + "effect": "4.0.0-beta.102" + }, + "devDependencies": { + "@types/node": "catalog:", + "nx": "23.1.0", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/app-builder/e2e/project.json b/packages/app-builder/e2e/project.json new file mode 100644 index 00000000..d26f4bda --- /dev/null +++ b/packages/app-builder/e2e/project.json @@ -0,0 +1,42 @@ +{ + "name": "@effectify/app-builder-e2e", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/app-builder/e2e/src", + "projectType": "library", + "tags": ["app-builder", "type:e2e", "npm:private"], + "targets": { + "build": { + "executor": "nx:run-commands", + "options": { + "command": "tsc -p tsconfig.lib.json", + "cwd": "packages/app-builder/e2e" + } + }, + "typecheck": { + "executor": "nx:run-commands", + "options": { + "command": "tsc --noEmit -p tsconfig.lib.json && tsc --noEmit -p tsconfig.spec.json", + "cwd": "packages/app-builder/e2e" + } + }, + "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" + } + }, + "lint": { + "executor": "nx-oxlint:lint", + "options": { + "lintFilePatterns": ["packages/app-builder/e2e/**/*.{ts,mts}"] + } + } + } +} diff --git a/packages/app-builder/e2e/src/golden-workspace.ts b/packages/app-builder/e2e/src/golden-workspace.ts new file mode 100644 index 00000000..766e5885 --- /dev/null +++ b/packages/app-builder/e2e/src/golden-workspace.ts @@ -0,0 +1,555 @@ +import { spawn, type ChildProcess } from "node:child_process" +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" +import { basename, dirname, join } from "node:path" +import { fileURLToPath } from "node:url" + +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 + readonly label: string + readonly output?: string +} + +export interface GoldenWorkspaceEvidence { + readonly cleanup: { + readonly daemonStopped: boolean + readonly storeRemoved: boolean + readonly workspaceRemoved: boolean + } + readonly commands: ReadonlyArray + 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 { + readonly environment: NodeJS.ProcessEnv + readonly store: string +} + +const exists = async (path: string): Promise => + stat(path) + .then(() => true) + .catch(() => false) + +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"), + NO_COLOR: "1", + NX_CACHE_DIRECTORY: join(workspace, "nx-cache"), + NX_DAEMON: "false", + NX_WORKSPACE_DATA_DIRECTORY: join(workspace, "nx-workspace-data"), + PATH: process.env.PATH ?? "", + PNPM_HOME: join(workspace, "pnpm-home"), +}) + +const run = async ( + cwd: string, + argv: ReadonlyArray, + environment: NodeJS.ProcessEnv, + timeout = commandTimeout, + 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) => { + childError = error + reject(error) + }) + child.once("close", (code) => { + 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.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 => + new Promise((resolve, reject) => { + const server = createServer() + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + const address = server.address() + if (address === null || typeof address === "string") { + reject(new Error("Unable to reserve a local registry port")) + return + } + server.close((error) => (error === undefined ? resolve(address.port) : reject(error))) + }) + }) + +const waitForRegistry = async (url: string): Promise => { + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + const response = await fetch(`${url}/-/ping`, { signal: AbortSignal.timeout(500) }) + if (response.ok) return + } catch { + // The isolated registry has not bound its loopback listener yet. + } + await sleep(100) + } + throw new Error(`Isolated Verdaccio registry did not become ready at ${url}`) +} + +const write = async (path: string, content: string): Promise => { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) +} + +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 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 } + } + } + if (terminal.terminal?._tag !== "Success" || terminal.terminal.result?.writtenPaths === undefined) { + throw new Error("Public CLI generation did not return successful materialization evidence") + } + 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")])), + ) + +const readRootProjects = async (): Promise> => { + const result = await run(repositoryRoot, ["pnpm", "nx", "show", "projects", "--json"], { + CI: "true", + HOME: process.env.HOME ?? repositoryRoot, + NX_DAEMON: "false", + PATH: process.env.PATH ?? "", + }) + if (result.exitCode !== 0) throw new Error(`Root Nx graph inspection failed: ${result.stderr}`) + return JSON.parse(result.stdout) as ReadonlyArray +} + +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 verdaccioPath = fileURLToPath( + new URL( + "../../../../node_modules/.pnpm/verdaccio@6.7.4_encoding@0.1.13_typanion@3.14.0/node_modules/verdaccio/bin/verdaccio", + import.meta.url, + ), +) + +/** Runs a real installed public CLI proof and removes its isolated registry and files. */ +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 { + await mkdir(state.environment.HOME!, { recursive: true }) + const port = await freePort() + const config = join(workspace, "registry/config.yaml") + await write(config, registryConfig(workspace)) + registry = spawn(process.execPath, [verdaccioPath, "--config", config, "--listen", `127.0.0.1:${port}`], { + cwd: workspace, + detached: true, + env: state.environment, + shell: false, + stdio: "ignore", + }) + 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 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", ...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") { + await appendFile(join(workspace, "apps/admin-console/src/index.ts"), "\nexport const invalidProof: string = 1\n") + const argv = ["pnpm", "exec", "nx", "run", "@acme/admin-console:typecheck"] + const failure = await run(workspace, argv, state.environment) + commands.push({ + argv, + exitCode: failure.exitCode, + label: "verification-failure", + output: `${failure.stdout}${failure.stderr}`, + }) + const output = `${failure.stdout}${failure.stderr}` + if (failure.exitCode === 0 || !output.includes("src/index.ts") || !output.includes("TS2322")) { + throw new Error("Generated Nx typecheck did not report the intentional TypeScript corruption") + } + } else if (scenario === "interrupted") { + const ready = join(workspace, ".interruption-ready.json") + await appendFile( + join(workspace, "apps/admin-console/tests/task.test.ts"), + `\nit("publishes interruption readiness", async () => {\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" }) + if (graph.exitCode !== 0) throw new Error(`Nested Nx graph failed: ${graph.stderr}`) + const projectNames = Object.keys( + (JSON.parse(graph.stdout) as { readonly graph: { readonly nodes: Record } }).graph.nodes, + ).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", `@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 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" + } + 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, + } + task = { events: runtimeEvidence.events, id: runtimeEvidence.id, state: runtimeEvidence.state } + outcome = "success" + } + } finally { + const daemonStopped = registry === undefined ? true : await stop(registry) + await rm(state.store, { force: true, recursive: true }) + await rm(workspace, { force: true, recursive: true }) + cleanup = { + daemonStopped, + storeRemoved: !(await exists(state.store)), + workspaceRemoved: !(await exists(workspace)), + } + } + + const rootProjectNames = await readRootProjects() + 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, task, termination } +} diff --git a/packages/app-builder/e2e/tests/golden.e2e.test.ts b/packages/app-builder/e2e/tests/golden.e2e.test.ts new file mode 100644 index 00000000..8d5151ea --- /dev/null +++ b/packages/app-builder/e2e/tests/golden.e2e.test.ts @@ -0,0 +1,155 @@ +import { expect, it } from "vitest" + +type CommandEvidence = { + readonly argv: ReadonlyArray + readonly exitCode: number + readonly label: string + readonly output?: string +} + +interface GoldenWorkspaceEvidence { + readonly cleanup: { + readonly daemonStopped: boolean + readonly storeRemoved: boolean + readonly workspaceRemoved: boolean + } + readonly commands: ReadonlyArray + readonly outcome: "interrupted" | "success" | "verification-failure" + readonly regeneration: { + readonly changedPaths: ReadonlyArray + readonly secondWritePaths: ReadonlyArray + } + readonly rootProjectNames: ReadonlyArray + readonly task: { + readonly events: ReadonlyArray + 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 runGoldenCommandTimeout: (timeout?: number) => Promise + readonly runGoldenNestedWorkspace: (options?: { + readonly scenario?: GoldenWorkspaceEvidence["outcome"] + }) => Promise +} + +const golden = () => + import(new URL("../src/golden-workspace.js", import.meta.url).href) as Promise + +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, + argv: expect.arrayContaining(["pnpm", "install", "--frozen-lockfile", "--store-dir", "--registry"]), + }), + expect.objectContaining({ label: "graph", exitCode: 0, argv: ["pnpm", "exec", "nx", "graph", "--print"] }), + expect.objectContaining({ + label: "test", + exitCode: 0, + argv: ["pnpm", "exec", "nx", "run", "@acme/admin-console:test"], + }), + expect.objectContaining({ + label: "typecheck", + exitCode: 0, + argv: ["pnpm", "exec", "nx", "run", "@acme/admin-console:typecheck"], + }), + expect.objectContaining({ + label: "build", + exitCode: 0, + 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.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.task.state).toBe("[]\n") + expect(result.regeneration).toEqual({ changedPaths: [], secondWritePaths: [] }) + 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) diff --git a/packages/app-builder/e2e/tsconfig.json b/packages/app-builder/e2e/tsconfig.json new file mode 100644 index 00000000..507afedb --- /dev/null +++ b/packages/app-builder/e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "ignoreDeprecations": "6.0" + }, + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.lib.json" }, { "path": "./tsconfig.spec.json" }] +} diff --git a/packages/app-builder/e2e/tsconfig.lib.json b/packages/app-builder/e2e/tsconfig.lib.json new file mode 100644 index 00000000..1ebd0d05 --- /dev/null +++ b/packages/app-builder/e2e/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/app-builder/e2e/tsconfig.spec.json b/packages/app-builder/e2e/tsconfig.spec.json new file mode 100644 index 00000000..37786497 --- /dev/null +++ b/packages/app-builder/e2e/tsconfig.spec.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "vitest", "vitest/globals", "vitest/importMeta"] + }, + "include": ["vitest.config.mts", "tests/**/*.ts"] +} diff --git a/packages/app-builder/e2e/vitest.config.mts b/packages/app-builder/e2e/vitest.config.mts new file mode 100644 index 00000000..3ee89fa7 --- /dev/null +++ b/packages/app-builder/e2e/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + root: __dirname, + cacheDir: "../../../node_modules/.vite/packages/app-builder/e2e", + test: { + name: "@effectify/app-builder-e2e", + environment: "node", + include: ["tests/**/*.test.ts"], + watch: false, + }, +}) diff --git a/packages/app-builder/generation/package.json b/packages/app-builder/generation/package.json index 9aa06a6f..40cd510b 100644 --- a/packages/app-builder/generation/package.json +++ b/packages/app-builder/generation/package.json @@ -3,6 +3,8 @@ "version": "0.0.0", "type": "module", "dependencies": { + "@effectify/app-builder-contracts": "workspace:*", + "ejs": "5.0.1", "tslib": "catalog:" }, "peerDependencies": { @@ -19,6 +21,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@nx/devkit": "23.1.0", + "@types/ejs": "3.1.5", "@types/node": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/app-builder/generation/project.json b/packages/app-builder/generation/project.json index 601cbbea..c1a42714 100644 --- a/packages/app-builder/generation/project.json +++ b/packages/app-builder/generation/project.json @@ -10,7 +10,14 @@ "options": { "main": "packages/app-builder/generation/src/index.ts", "tsConfig": "packages/app-builder/generation/tsconfig.lib.json", - "outputPath": "packages/app-builder/generation/dist" + "outputPath": "packages/app-builder/generation/dist", + "assets": [ + { + "input": "packages/app-builder/generation/src/templates/assets", + "glob": "**/*", + "output": "src/templates/assets" + } + ] } }, "typecheck": { @@ -22,6 +29,7 @@ }, "test": { "executor": "nx:run-commands", + "dependsOn": ["build"], "options": { "command": "../../../node_modules/.bin/vitest run --config vitest.config.mts", "cwd": "packages/app-builder/generation" diff --git a/packages/app-builder/generation/src/catalog.ts b/packages/app-builder/generation/src/catalog.ts index 71c5d0ca..6bdc2812 100644 --- a/packages/app-builder/generation/src/catalog.ts +++ b/packages/app-builder/generation/src/catalog.ts @@ -1,6 +1,19 @@ import * as Data from "effect/Data" import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" import type { Capability, CreationIntent } from "./intent.js" +import { + type AtomicGenerator, + type Capability as KernelCapability, + CapabilityGraphFailure, + type FileContribution, + ContributionConflict, + decodeRenderContext, + type FiniteCatalog, + type GenerationFailure, + type GeneratorId, + SchemaContextFailure, +} from "./kernel.js" export interface OfficialCatalogEntry { readonly capability: Capability @@ -32,6 +45,11 @@ export interface CatalogResolution { readonly entries: ReadonlyArray } +export interface CatalogComposition { + readonly contributions: ReadonlyArray + readonly generatorIds: ReadonlyArray +} + export class CatalogResolutionError extends Data.TaggedError("CatalogResolutionError")<{ readonly capability: string readonly reason: "incompatible" | "unavailable" @@ -118,3 +136,98 @@ export const resolveCatalog = ( return Object.freeze({ entries: Object.freeze(entries) }) }) + +const composeFailure = (capability: string, reason: CapabilityGraphFailure["reason"]) => + Effect.fail(new CapabilityGraphFailure({ capability, reason })) + +export const composeCatalog = < + Input, + const Generators extends readonly [AtomicGenerator, ...AtomicGenerator[]], +>(options: { + readonly catalog: FiniteCatalog + readonly context: unknown + readonly input: unknown + readonly selected: ReadonlyArray +}): Effect.Effect => + Effect.gen(function* () { + const context = yield* decodeRenderContext(options.context) + const byId = new Map>() + const providers = new Map>() + for (const generator of options.catalog) { + if (byId.has(generator.id)) return yield* composeFailure(generator.id, "duplicate-generator-id") + byId.set(generator.id, generator) + for (const capability of generator.provides) { + if (providers.has(capability)) { + return yield* Effect.fail(new ContributionConflict({ identity: capability, reason: "duplicate-provider" })) + } + providers.set(capability, generator) + } + } + + const closure = new Set() + const visiting = new Set() + const visit = (id: GeneratorId): Effect.Effect => { + const generator = byId.get(id) + if (generator === undefined) return composeFailure(id, "missing-capability") + if (closure.has(id)) return Effect.void + if (visiting.has(id)) return composeFailure(id, "cyclic-capability") + visiting.add(id) + return Effect.forEach(generator.requires, (capability) => { + const provider = providers.get(capability) + return provider === undefined ? composeFailure(capability, "missing-capability") : visit(provider.id) + }).pipe( + Effect.asVoid, + Effect.tap(() => + Effect.sync(() => { + visiting.delete(id) + closure.add(id) + }), + ), + ) + } + yield* Effect.forEach(options.selected, visit) + + const dependencies = new Map( + [...closure].map((id) => { + const generator = byId.get(id) + const required = + generator === undefined ? [] : generator.requires.flatMap((capability) => [providers.get(capability)?.id]) + return [ + id, + new Set(required.filter((value): value is GeneratorId => value !== undefined && closure.has(value))), + ] as const + }), + ) + const remaining = new Set(closure) + const generatorIds: Array = [] + while (remaining.size > 0) { + const ready = [...remaining].filter((id) => dependencies.get(id)?.size === 0).sort() + if (ready.length === 0) return yield* composeFailure([...remaining].sort()[0] ?? "catalog", "cyclic-capability") + for (const id of ready) { + remaining.delete(id) + generatorIds.push(id) + for (const required of dependencies.values()) required.delete(id) + } + } + + const contributions: Array = [] + const paths = new Set() + for (const id of generatorIds) { + const generator = byId.get(id) + if (generator === undefined) continue + const input = yield* Schema.decodeUnknownEffect(generator.InputSchema)(options.input).pipe( + Effect.mapError(() => new SchemaContextFailure({ boundary: "intent", reason: "schema" })), + ) + for (const contribution of yield* generator.render(input, context)) { + if (paths.has(contribution.path)) { + return yield* Effect.fail(new ContributionConflict({ identity: contribution.path, reason: "duplicate-path" })) + } + paths.add(contribution.path) + contributions.push(Object.freeze({ ...contribution })) + } + } + return Object.freeze({ + contributions: Object.freeze(contributions.sort((left, right) => left.path.localeCompare(right.path))), + generatorIds: Object.freeze(generatorIds), + }) + }) diff --git a/packages/app-builder/generation/src/cjson.ts b/packages/app-builder/generation/src/cjson.ts new file mode 100644 index 00000000..38ba5b12 --- /dev/null +++ b/packages/app-builder/generation/src/cjson.ts @@ -0,0 +1,23 @@ +import { canonicalJsonBytes, canonicalizeJson } from "@effectify/app-builder-contracts" +import { createHash } from "node:crypto" +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Result from "effect/Result" + +export const CanonicalJsonAlgorithm = "effectify-cjson/1" as const + +export class CanonicalJsonError extends Data.TaggedError("CanonicalJsonError")<{ + readonly reason: "unsupported-json" +}> {} + +const sha256 = (value: string | Uint8Array): string => `sha256:${createHash("sha256").update(value).digest("hex")}` + +export const canonicalDigest = (input: unknown): Effect.Effect => + Result.match(canonicalizeJson(input), { + onFailure: () => Effect.fail(new CanonicalJsonError({ reason: "unsupported-json" })), + onSuccess: (value) => Effect.succeed(sha256(canonicalJsonBytes(value))), + }) + +export const normalizeSource = (source: string): string => source.replace(/\r\n?/g, "\n") + +export const canonicalSourceDigest = (source: string): string => sha256(normalizeSource(source)) diff --git a/packages/app-builder/generation/src/generators/block.ts b/packages/app-builder/generation/src/generators/block.ts index 7d49b276..8209c3d0 100644 --- a/packages/app-builder/generation/src/generators/block.ts +++ b/packages/app-builder/generation/src/generators/block.ts @@ -1,4 +1,5 @@ -import { todoTemplateContent } from "../templates/todo/index.js" +import { renderTemplate, templateAsset, templateSubstitutions } from "../templates.js" +import { DefaultTodoRenderContext } from "./todo-v1-atomic.js" export type TodoGenerationBlockId = "model" | "port" | "event" | "use-case" | "integration-adapter" | "presentation" @@ -6,6 +7,7 @@ export interface GenerationBlockFile { readonly content: string readonly owner: string readonly path: string + readonly template?: import("../templates.js").TemplateAsset } export interface GenerationBlock { @@ -24,10 +26,23 @@ interface Definition { readonly requires: ReadonlyArray } -export const fromTodoTemplate = (path: string): Readonly<{ content: string; path: string }> => ({ - content: todoTemplateContent(path), - path, -}) +export const fromTodoTemplate = ( + path: string, +): Readonly<{ content: string; path: string; template?: import("../templates.js").TemplateAsset }> => { + const target = DefaultTodoRenderContext.packages.find(({ root }) => path.startsWith(`${root}/`))! + const layer = `layer-${DefaultTodoRenderContext.packages.indexOf(target) + 1}` + const relative = path + .slice(target.root.length + 1) + .replace("todo-runtime.test.ts", "__entityId__-runtime.test.ts") + .replace("todo.test.ts", "__entityId__.test.ts") + const template = templateAsset({ + directory: `blueprint/${layer}`, + group: `todo-v1-${layer}`, + sourcePath: `__targetRoot__/${relative}.template`, + substitutions: templateSubstitutions(DefaultTodoRenderContext, { targetRoot: target.root }), + }) + return Object.freeze({ content: renderTemplate(template), path, template }) +} export const defineTodoGenerationBlock = (definition: Definition): GenerationBlock => { const owner = `@effectify/app-builder/${definition.id}/1` diff --git a/packages/app-builder/generation/src/generators/event.ts b/packages/app-builder/generation/src/generators/event.ts index 7211e10f..36fee61f 100644 --- a/packages/app-builder/generation/src/generators/event.ts +++ b/packages/app-builder/generation/src/generators/event.ts @@ -1,7 +1,16 @@ -import { defineTodoGenerationBlock } from "./block.js" +import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" +import { defineTodoGenerator } from "./todo.js" + +export const eventGenerator = defineTodoGenerator({ + files: [{ relativePath: "src/event.ts", sourcePath: "__targetRoot__/src/event.ts.template" }], + id: "event", + packageId: "domain", + provides: ["todo-event"], + requires: ["todo-model"], +}) export const eventBlock = defineTodoGenerationBlock({ - files: [{ content: 'export type { TodoEvent } from "./index.js"\n', path: "packages/todo/domain/src/events.ts" }], + files: [fromTodoTemplate("packages/todo/domain/src/events.ts")], id: "event", manifestPath: "packages/todo/domain/.effectify/generation/event.json", provides: ["todo.events"], diff --git a/packages/app-builder/generation/src/generators/index.ts b/packages/app-builder/generation/src/generators/index.ts index d1f80f26..38d848af 100644 --- a/packages/app-builder/generation/src/generators/index.ts +++ b/packages/app-builder/generation/src/generators/index.ts @@ -1,21 +1,85 @@ -import type { GenerationBlock, TodoGenerationBlockId } from "./block.js" -import { eventBlock } from "./event.js" -import { integrationAdapterBlock } from "./integration-adapter.js" -import { modelBlock } from "./model.js" -import { portBlock } from "./port.js" -import { presentationBlock } from "./presentation.js" -import { useCaseBlock } from "./use-case.js" - -export { type GenerationBlock, type GenerationBlockFile, type TodoGenerationBlockId } from "./block.js" - -export const TodoGenerationBlockIds = [ - "model", - "port", - "event", - "use-case", - "integration-adapter", - "presentation", -] as const +import { composeCatalog } from "../catalog.js" +import * as Effect from "effect/Effect" +import { CapabilityGraphFailure, decodeRenderContext, defineCatalog, type AtomicGenerator } from "../kernel.js" +import type { GenerationBlock } from "./block.js" +import { eventBlock, eventGenerator } from "./event.js" +import { integrationAdapterBlock, integrationAdapterGenerator } from "./integration-adapter.js" +import { modelBlock, modelGenerator } from "./model.js" +import { portBlock, portGenerator } from "./port.js" +import { presentationBlock, presentationGenerator } from "./presentation.js" +import { TodoGenerationBlockIds, todoSurfaceInput, type TodoGenerationBlockId } from "./todo.js" +import { packageSurfaceGenerator, workspaceSurfaceGenerator } from "./surfaces.js" +import { useCaseBlock, useCaseGenerator } from "./use-case.js" +import { isTodoV1Context, TodoV1AtomicCatalog, todoV1GeneratorIds } from "./todo-v1-atomic.js" +export { DefaultTodoRenderContext } from "./todo-v1-atomic.js" + +export { packageSurfaceGenerator, SurfaceCatalog, WorkspaceRootFiles, workspaceSurfaceGenerator } from "./surfaces.js" +export type { WorkspaceRootFile } from "./surfaces.js" + +export { type GenerationBlock, type GenerationBlockFile } from "./block.js" +export { TodoGenerationBlockIds, type TodoGenerationBlockId } from "./todo.js" + +const atomicById: Readonly>> = { + event: eventGenerator, + "integration-adapter": integrationAdapterGenerator, + model: modelGenerator, + port: portGenerator, + presentation: presentationGenerator, + "use-case": useCaseGenerator, +} + +export const TodoAtomicCatalog = defineCatalog([ + workspaceSurfaceGenerator, + packageSurfaceGenerator, + ...TodoGenerationBlockIds.map((id) => atomicById[id]), +]) + +const isTodoGenerationBlockId = (value: string): value is TodoGenerationBlockId => + TodoGenerationBlockIds.some((id) => id === value) + +export const composeTodoAtomic = (context: unknown, selected: ReadonlyArray = TodoGenerationBlockIds) => + decodeRenderContext(context).pipe( + Effect.flatMap((decoded) => { + const invalid = selected.find((id) => !isTodoGenerationBlockId(id)) + if (invalid !== undefined) { + return Effect.fail(new CapabilityGraphFailure({ capability: invalid, reason: "missing-capability" })) + } + const capabilities = selected.filter(isTodoGenerationBlockId) + if (isTodoV1Context(decoded)) { + return composeCatalog({ + catalog: TodoV1AtomicCatalog, + context: decoded, + input: undefined, + selected: todoV1GeneratorIds(capabilities), + }) + } + return todoSurfaceInput(decoded, capabilities).pipe( + Effect.flatMap((input) => + composeCatalog({ + catalog: TodoAtomicCatalog, + context: decoded, + input, + selected: capabilities.map((id) => atomicById[id].id), + }), + ), + ) + }), + ) + +export const composeTodoV1Atomic = (context: unknown, selected: ReadonlyArray = TodoGenerationBlockIds) => + decodeRenderContext(context).pipe( + Effect.flatMap((decoded) => { + const invalid = selected.find((id) => !isTodoGenerationBlockId(id)) + return invalid === undefined + ? composeCatalog({ + catalog: TodoV1AtomicCatalog, + context: decoded, + input: undefined, + selected: todoV1GeneratorIds(selected.filter(isTodoGenerationBlockId)), + }) + : Effect.fail(new CapabilityGraphFailure({ capability: invalid, reason: "missing-capability" })) + }), + ) const blockById: ReadonlyMap = new Map([ [modelBlock.id, modelBlock], @@ -32,7 +96,6 @@ const blockFor = (id: TodoGenerationBlockId): GenerationBlock => { return block } -/** Returns a dependency-closed, identity-ordered selection without a mutation adapter. */ export const composeTodoGenerationBlocks = ( selected: ReadonlyArray, ): ReadonlyArray => { diff --git a/packages/app-builder/generation/src/generators/integration-adapter.ts b/packages/app-builder/generation/src/generators/integration-adapter.ts index a89491c5..affbb6c2 100644 --- a/packages/app-builder/generation/src/generators/integration-adapter.ts +++ b/packages/app-builder/generation/src/generators/integration-adapter.ts @@ -1,4 +1,13 @@ import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" +import { defineTodoGenerator } from "./todo.js" + +export const integrationAdapterGenerator = defineTodoGenerator({ + files: [{ relativePath: "src/adapter.ts", sourcePath: "__targetRoot__/src/adapter.ts.template" }], + id: "integration-adapter", + packageId: "infrastructure", + provides: ["todo-file-adapter"], + requires: ["todo-use-case"], +}) export const integrationAdapterBlock = defineTodoGenerationBlock({ files: [ diff --git a/packages/app-builder/generation/src/generators/model.ts b/packages/app-builder/generation/src/generators/model.ts index 29a8a929..2dd0f872 100644 --- a/packages/app-builder/generation/src/generators/model.ts +++ b/packages/app-builder/generation/src/generators/model.ts @@ -1,4 +1,16 @@ import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" +import { defineTodoGenerator } from "./todo.js" + +export const modelGenerator = defineTodoGenerator({ + files: [ + { relativePath: "src/model.ts", sourcePath: "__targetRoot__/src/model.ts.template" }, + { relativePath: "tests/model.test.ts", sourcePath: "__targetRoot__/tests/model.test.ts.template" }, + ], + id: "model", + packageId: "domain", + provides: ["todo-model"], + requires: ["package-surface"], +}) export const modelBlock = defineTodoGenerationBlock({ files: [ diff --git a/packages/app-builder/generation/src/generators/port.ts b/packages/app-builder/generation/src/generators/port.ts index 1d6551d8..d515136d 100644 --- a/packages/app-builder/generation/src/generators/port.ts +++ b/packages/app-builder/generation/src/generators/port.ts @@ -1,4 +1,13 @@ import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" +import { defineTodoGenerator } from "./todo.js" + +export const portGenerator = defineTodoGenerator({ + files: [{ relativePath: "src/port.ts", sourcePath: "__targetRoot__/src/port.ts.template" }], + id: "port", + packageId: "application", + provides: ["todo-port"], + requires: ["todo-model", "todo-event"], +}) export const portBlock = defineTodoGenerationBlock({ files: [fromTodoTemplate("packages/todo/application/src/index.ts")], diff --git a/packages/app-builder/generation/src/generators/presentation.ts b/packages/app-builder/generation/src/generators/presentation.ts index 9896cfb4..06dff95d 100644 --- a/packages/app-builder/generation/src/generators/presentation.ts +++ b/packages/app-builder/generation/src/generators/presentation.ts @@ -1,4 +1,13 @@ import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" +import { defineTodoGenerator } from "./todo.js" + +export const presentationGenerator = defineTodoGenerator({ + files: [{ relativePath: "src/presentation.ts", sourcePath: "__targetRoot__/src/entrypoint.ts.template" }], + id: "presentation", + packageId: "presentation", + provides: ["todo-cli-presentation"], + requires: ["todo-file-adapter"], +}) export const presentationBlock = defineTodoGenerationBlock({ files: [fromTodoTemplate("apps/todo-cli/src/index.ts"), fromTodoTemplate("apps/todo-cli/tests/todo.test.ts")], diff --git a/packages/app-builder/generation/src/generators/surfaces.ts b/packages/app-builder/generation/src/generators/surfaces.ts new file mode 100644 index 00000000..6d1647c4 --- /dev/null +++ b/packages/app-builder/generation/src/generators/surfaces.ts @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import { + capabilities, + defineCatalog, + identifier, + safeRelativePath, + sourceDigest, + type AtomicGenerator, + type FileContribution, + type GenerationFailure, + type PackageTarget, + type RenderContext, + RenderFailure, +} from "../kernel.js" +import { + renderTemplate, + templateAsset, + templateSubstitutions, + type TemplateAsset, + type TemplateSubstitutions, +} from "../templates.js" + +export const WorkspaceRootFiles = [ + "nx.json", + "package.json", + "pnpm-workspace.yaml", + "tsconfig.build.json", + "vitest.config.mts", +] as const + +export type WorkspaceRootFile = (typeof WorkspaceRootFiles)[number] + +const PackageSurfaceInputSchema = Schema.Struct({ + packages: Schema.Array( + Schema.Struct({ + dependencies: Schema.Array(Schema.String), + exports: Schema.Array(Schema.Struct({ from: Schema.String, name: Schema.String })), + packageId: Schema.String, + }), + ), +}) + +export interface PackageSurfaceInput extends Schema.Schema.Type {} + +const digest = (content: string) => sourceDigest(`sha256:${createHash("sha256").update(content).digest("hex")}`) +const contribution = ( + content: string, + owner: string, + packageId: FileContribution["package"], + path: string, + surface: string, + template: TemplateAsset, +) => + Object.freeze({ + bytes: new TextEncoder().encode(content), + mode: "100644", + owner: identifier(owner), + package: packageId, + path: safeRelativePath(path), + sourceDigest: digest(content), + surface: identifier(surface), + template, + }) + +const workspaceSubstitutions = (context: RenderContext): TemplateSubstitutions => templateSubstitutions(context) + +export const workspaceSurfaceGenerator: AtomicGenerator = { + InputSchema: PackageSurfaceInputSchema, + id: identifier("workspace-surface"), + provides: capabilities("workspace-surface"), + render: (_input, context): Effect.Effect, GenerationFailure> => { + const substitutions = workspaceSubstitutions(context) + return Effect.succeed( + Object.freeze( + WorkspaceRootFiles.map((path) => { + const template = templateAsset({ + directory: "generic/workspace", + group: "workspace-surface", + sourcePath: `${path}.template`, + substitutions, + }) + return contribution( + renderTemplate(template), + `workspace-surface-${path.replace(/[^a-z0-9]+/g, "-")}`, + "workspace", + path, + "workspace", + template, + ) + }), + ), + ) + }, + requires: capabilities(), + version: "1", +} + +const safeExport = (entry: PackageSurfaceInput["packages"][number]["exports"][number]): boolean => + /^[A-Za-z][A-Za-z0-9]*$/.test(entry.name) && + /^\.\/[A-Za-z0-9][A-Za-z0-9./_-]*$/.test(entry.from) && + !entry.from.includes("..") + +export const packageSurfaceGenerator: AtomicGenerator = { + InputSchema: PackageSurfaceInputSchema, + id: identifier("package-surface"), + provides: capabilities("package-surface"), + render: (input, context): Effect.Effect, GenerationFailure> => { + const definitions = new Map(input.packages.map((definition) => [definition.packageId, definition])) + if (definitions.size !== context.packages.length || definitions.size !== input.packages.length) { + return Effect.fail(new RenderFailure({ generatorId: "package-surface", reason: "unsafe-path" })) + } + return Effect.forEach(context.packages, (target) => { + const definition = definitions.get(target.id) + const dependencies = definition?.dependencies.map((id) => + context.packages.find((candidate) => candidate.id === id), + ) + if ( + definition === undefined || + dependencies === undefined || + dependencies.some((dependency) => dependency === undefined || dependency.id === target.id) || + new Set(definition.dependencies).size !== definition.dependencies.length || + new Set(definition.exports.map((entry) => entry.name)).size !== definition.exports.length || + !definition.exports.every(safeExport) + ) { + return Effect.fail(new RenderFailure({ generatorId: "package-surface", reason: "unsafe-path" })) + } + const owner = `package-surface-${target.id}` + const resolved = dependencies.filter((dependency): dependency is PackageTarget => dependency !== undefined) + const substitutions = templateSubstitutions(context, { + dependenciesJson: JSON.stringify( + Object.fromEntries(resolved.map((dependency) => [dependency.name, "workspace:*"]).sort()), + null, + 2, + ), + exportsJson: JSON.stringify( + [...definition.exports].sort( + (left, right) => left.name.localeCompare(right.name) || left.from.localeCompare(right.from), + ), + ), + packageName: target.name, + targetRoot: target.root, + }) + const manifestTemplate = templateAsset({ + directory: "generic/package", + group: `package-surface-${target.id}`, + sourcePath: "__targetRoot__/package.json.template", + substitutions, + }) + const barrelTemplate = templateAsset({ + directory: "generic/package", + group: `package-surface-${target.id}`, + sourcePath: "__targetRoot__/src/index.ts.template", + substitutions, + }) + return Effect.succeed([ + contribution( + renderTemplate(manifestTemplate), + `${owner}-manifest`, + target.id, + `${target.root}/package.json`, + "package-surface", + manifestTemplate, + ), + contribution( + renderTemplate(barrelTemplate), + `${owner}-barrel`, + target.id, + `${target.root}/src/index.ts`, + "package-surface", + barrelTemplate, + ), + ]) + }).pipe(Effect.map((files) => Object.freeze(files.flat()))) + }, + requires: capabilities("workspace-surface"), + version: "1", +} + +export const SurfaceCatalog = defineCatalog([workspaceSurfaceGenerator, packageSurfaceGenerator]) diff --git a/packages/app-builder/generation/src/generators/todo-v1-atomic.ts b/packages/app-builder/generation/src/generators/todo-v1-atomic.ts new file mode 100644 index 00000000..0ada1398 --- /dev/null +++ b/packages/app-builder/generation/src/generators/todo-v1-atomic.ts @@ -0,0 +1,212 @@ +import { createHash } from "node:crypto" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import * as Kernel from "../kernel.js" +import { renderTemplate, templateAsset, templateSubstitutions } from "../templates.js" +const contribution = ( + context: Kernel.RenderContext, + sourcePath: string, + owner: string, + packageId: Kernel.FileContribution["package"], + path: string, + surface: string, +): Kernel.FileContribution => { + const [directory, ...relativeSource] = sourcePath.split("/") + const targetRoot = packageId === "workspace" ? undefined : context.packages.find(({ id }) => id === packageId)?.root + const template = templateAsset({ + directory: `blueprint/${directory}`, + group: `todo-v1-${packageId}`, + sourcePath: relativeSource.join("/"), + substitutions: templateSubstitutions(context, targetRoot === undefined ? {} : { targetRoot }), + }) + const content = renderTemplate(template) + return Object.freeze({ + bytes: new TextEncoder().encode(content), + mode: "100644", + owner: Kernel.identifier(owner), + package: packageId, + path: Kernel.safeRelativePath(path), + sourceDigest: Kernel.sourceDigest(`sha256:${createHash("sha256").update(content).digest("hex")}`), + surface: Kernel.identifier(surface), + template, + }) +} +const workspace: Kernel.AtomicGenerator = { + InputSchema: Schema.Unknown, + id: Kernel.identifier("todo-v1-workspace"), + provides: Kernel.capabilities("todo-v1-workspace"), + requires: Kernel.capabilities(), + version: "1", + render: (_input, context) => + Effect.succeed( + Object.freeze( + ["nx.json", "package.json", "pnpm-workspace.yaml", "tsconfig.build.json", "vitest.config.mts"].map((path) => + contribution( + context, + `workspace/${path}.template`, + `workspace-surface-${path.replace(/[^a-z0-9]+/g, "-")}`, + "workspace", + path, + "workspace", + ), + ), + ), + ), +} +const definitions = [ + ["domain", [], "todo-v1-domain-surface"], + ["application", ["domain"], "todo-v1-application-surface"], + ["infrastructure", ["application", "domain"], "todo-v1-infrastructure-surface"], + ["presentation", ["infrastructure", "application", "domain"], "todo-v1-presentation-surface"], +] as const +const packageSurface = ( + id: (typeof definitions)[number][0], + dependencies: ReadonlyArray, + capability: string, +): Kernel.AtomicGenerator => ({ + InputSchema: Schema.Unknown, + id: Kernel.identifier(capability), + provides: Kernel.capabilities(capability), + requires: Kernel.capabilities("todo-v1-workspace"), + version: "1", + render: (_input, context) => { + const target = context.packages.find((candidate) => candidate.id === id) + const resolved = dependencies.map((dependency) => context.packages.find((candidate) => candidate.id === dependency)) + if (target === undefined || resolved.some((dependency) => dependency === undefined)) + return Effect.fail( + new Kernel.RenderFailure({ + generatorId: capability, + reason: "unsafe-path", + }), + ) + const owner = `package-surface-${id}` + return Effect.succeed( + Object.freeze([ + contribution( + context, + `${id === "domain" ? "layer-1" : id === "application" ? "layer-2" : id === "infrastructure" ? "layer-3" : "layer-4"}/__targetRoot__/package.json.template`, + `${owner}-manifest`, + target.id, + `${target.root}/package.json`, + "package-surface", + ), + contribution( + context, + `${id === "domain" ? "layer-1" : id === "application" ? "layer-2" : id === "infrastructure" ? "layer-3" : "layer-4"}/__targetRoot__/src/index.ts.template`, + `${owner}-barrel`, + target.id, + `${target.root}/src/index.ts`, + "package-surface", + ), + ]), + ) + }, +}) +const templateTest = (packageId: string, sourcePath: string, relativePath: string) => + [packageId, sourcePath, relativePath] as const +const leafFiles = { + model: templateTest( + "domain", + "layer-1/__targetRoot__/tests/__entityId__.test.ts.template", + "tests/__entityId__.test.ts", + ), + event: templateTest("domain", "layer-1/__targetRoot__/src/events.ts.template", "src/events.ts"), + port: ["application", undefined, undefined], + "use-case": templateTest("application", "layer-2/__targetRoot__/src/use-case.ts.template", "src/use-case.ts"), + "integration-adapter": templateTest( + "infrastructure", + "layer-3/__targetRoot__/tests/__entityId__-runtime.test.ts.template", + "tests/__entityId__-runtime.test.ts", + ), + presentation: templateTest( + "presentation", + "layer-4/__targetRoot__/tests/__entityId__.test.ts.template", + "tests/__entityId__.test.ts", + ), +} as const +const leaf = (id: keyof typeof leafFiles, requires: ReadonlyArray): Kernel.AtomicGenerator => { + const [packageId, sourcePath, relativePath] = leafFiles[id] + return { + InputSchema: Schema.Unknown, + id: Kernel.identifier(`todo-v1-${id}`), + provides: Kernel.capabilities(`todo-v1-${id}`), + requires: Kernel.capabilities(...requires), + version: "1", + render: (_input, context) => { + const target = context.packages.find((candidate) => candidate.id === packageId) + if (target === undefined) + return Effect.fail( + new Kernel.RenderFailure({ + generatorId: `todo-v1-${id}`, + reason: "unsafe-path", + }), + ) + if (sourcePath === undefined || relativePath === undefined) return Effect.succeed(Object.freeze([])) + const path = `${target.root}/${relativePath.replace("__entityId__", context.entity.id)}` + return Effect.succeed( + Object.freeze([ + contribution( + context, + sourcePath, + `todo-${id}-${path.replace(/[^a-z0-9]+/g, "-")}`, + target.id, + path, + "todo-capability", + ), + ]), + ) + }, + } +} +const [domainSurface, applicationSurface, infrastructureSurface, presentationSurface] = definitions.map( + ([id, dependencies, capability]) => packageSurface(id, dependencies, capability), +) +const model = leaf("model", ["todo-v1-domain-surface"]) +const event = leaf("event", ["todo-v1-model", "todo-v1-domain-surface"]) +const port = leaf("port", ["todo-v1-model", "todo-v1-application-surface"]) +const useCase = leaf("use-case", ["todo-v1-port", "todo-v1-application-surface"]) +const adapter = leaf("integration-adapter", ["todo-v1-use-case", "todo-v1-infrastructure-surface"]) +const presentation = leaf("presentation", ["todo-v1-integration-adapter", "todo-v1-presentation-surface"]) +export const DefaultTodoRenderContext = Object.freeze({ + version: "effectify.render-context/1" as const, + workspace: Object.freeze({ name: "todo-workspace", npmScope: "@effectify" }), + domain: Object.freeze({ id: "todo", name: "Todo", importName: "@effectify/todo-domain" }), + entity: Object.freeze({ id: "todo", singular: "Todo", plural: "Todos" }), + entrypoint: Object.freeze({ id: "todo-cli", name: "TodoCli", importName: "@effectify/todo-cli" }), + packages: Object.freeze( + [ + ["domain", "@effectify/todo-domain", "packages/todo/domain"], + ["application", "@effectify/todo-application", "packages/todo/application"], + ["infrastructure", "@effectify/todo-infrastructure", "packages/todo/infrastructure"], + ["presentation", "@effectify/todo-cli", "apps/todo-cli"], + ].map(([id, name, root]) => + Object.freeze({ id: Kernel.identifier(id!), name: name!, root: Kernel.safeRelativePath(root!) }), + ), + ), +}) +export const TodoV1AtomicCatalog = Kernel.defineCatalog([ + workspace, + domainSurface, + applicationSurface, + infrastructureSurface, + presentationSurface, + model, + event, + port, + useCase, + adapter, + presentation, +]) +const todoV1Identity = (context: Kernel.RenderContext): string => + JSON.stringify({ + version: context.version, + workspace: context.workspace, + domain: context.domain, + entity: context.entity, + entrypoint: context.entrypoint, + packages: [...context.packages].sort((left, right) => left.id.localeCompare(right.id)), + }) +const TodoV1Identity = todoV1Identity(DefaultTodoRenderContext) +export const isTodoV1Context = (context: Kernel.RenderContext): boolean => todoV1Identity(context) === TodoV1Identity +export const todoV1GeneratorIds = (selected: ReadonlyArray) => + selected.map((id) => Kernel.identifier(`todo-v1-${id}`)) diff --git a/packages/app-builder/generation/src/generators/todo.ts b/packages/app-builder/generation/src/generators/todo.ts new file mode 100644 index 00000000..e91c154b --- /dev/null +++ b/packages/app-builder/generation/src/generators/todo.ts @@ -0,0 +1,162 @@ +import { createHash } from "node:crypto" +import { relative } from "node:path/posix" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import * as Kernel from "../kernel.js" +import type { PackageSurfaceInput } from "./surfaces.js" +import { renderTemplate, templateAsset, templateSubstitutions, type TemplateSubstitutions } from "../templates.js" + +const ids = ["model", "port", "event", "use-case", "integration-adapter", "presentation"] as const +export { ids as TodoGenerationBlockIds } +export type TodoGenerationBlockId = (typeof ids)[number] + +const TodoPackageIds = ["domain", "application", "infrastructure", "presentation"] as const + +interface TodoGeneratorDefinition { + readonly files: ReadonlyArray> + readonly id: TodoGenerationBlockId + readonly packageId: string + readonly provides: ReadonlyArray + readonly requires: ReadonlyArray +} + +const digest = (content: string) => Kernel.sourceDigest(`sha256:${createHash("sha256").update(content).digest("hex")}`) +const importFrom = (context: Kernel.RenderContext, from: Kernel.PackageTarget, id: string): string => { + const target = context.packages.find((candidate) => candidate.id === id) + if (target === undefined) throw new Error(`Missing template package: ${id}`) + return relative(`${from.root}/src`, `${target.root}/src/index.js`).replace(/^(?!\.)/, "./") +} + +const substitutions = (context: Kernel.RenderContext, target: Kernel.PackageTarget): TemplateSubstitutions => + templateSubstitutions(context, { + applicationImport: importFrom(context, target, "application"), + domainImport: importFrom(context, target, "domain"), + infrastructureImport: importFrom(context, target, "infrastructure"), + targetRoot: target.root, + }) + +export const todoContribution = (options: { + readonly content: string + readonly owner: string + readonly path: string + readonly surface: string + readonly target: Kernel.PackageTarget + readonly template?: Kernel.FileContribution["template"] +}): Kernel.FileContribution => + Object.freeze({ + bytes: new TextEncoder().encode(options.content), + mode: "100644", + owner: Kernel.identifier(options.owner), + package: options.target.id, + path: Kernel.safeRelativePath(options.path), + sourceDigest: digest(options.content), + surface: Kernel.identifier(options.surface), + ...(options.template === undefined ? {} : { template: options.template }), + }) + +export const defineTodoGenerator = (definition: TodoGeneratorDefinition): Kernel.AtomicGenerator => ({ + InputSchema: Schema.Unknown, + id: Kernel.identifier(`todo-${definition.id}`), + provides: Kernel.capabilities(...definition.provides), + render: (_input, context): Effect.Effect, Kernel.GenerationFailure> => { + const target = context.packages.find((candidate) => candidate.id === definition.packageId) + if (target === undefined) { + return Effect.fail(new Kernel.RenderFailure({ generatorId: `todo-${definition.id}`, reason: "unsafe-path" })) + } + return Effect.succeed( + Object.freeze( + definition.files.map((file) => { + const template = templateAsset({ + directory: `generic/${definition.id === "presentation" ? "interface" : definition.id}`, + group: `todo-${definition.id}`, + sourcePath: file.sourcePath, + substitutions: substitutions(context, target), + }) + return todoContribution({ + content: renderTemplate(template), + owner: `todo-${definition.id}-${file.relativePath.replace(/[^a-z0-9]+/g, "-")}`, + path: `${target.root}/${file.relativePath}`, + surface: "todo-capability", + target, + template, + }) + }), + ), + ) + }, + requires: Kernel.capabilities(...definition.requires), + version: "1", +}) + +const selectedWithPrerequisites = (selected: ReadonlyArray) => { + const dependencies: Readonly>> = { + model: [], + event: ["model"], + port: ["event"], + "use-case": ["port"], + "integration-adapter": ["use-case"], + presentation: ["integration-adapter"], + } + const included = new Set() + const include = (id: TodoGenerationBlockId): void => { + if (included.has(id)) return + dependencies[id].forEach(include) + included.add(id) + } + selected.forEach(include) + return included +} + +const exportsFor = (included: ReadonlySet, entity: string) => ({ + domain: [ + ...(included.has("model") + ? [ + `${entity}Id`, + entity, + `${entity}TextInvalid`, + `${entity}NotFound`, + `${entity}AlreadyCompleted`, + `${entity}IdExhausted`, + `${entity}PersistenceError`, + ] + : []), + ...(included.has("event") ? [`${entity}Event`] : []), + ], + application: [ + ...(included.has("port") + ? [`${entity}Repository`, `${entity}Clock`, `${entity}IdGenerator`, `${entity}Events`] + : []), + ...(included.has("use-case") ? [`${entity}Application`, "layer"] : []), + ], + infrastructure: included.has("integration-adapter") ? [`${entity}TestProbe`, "testLayer", "liveLayer"] : [], + presentation: included.has("presentation") ? ["createTestRuntime", "createLiveRuntime", "renderEvent"] : [], +}) + +export const todoSurfaceInput = ( + context: Kernel.RenderContext, + selected: ReadonlyArray, +): Effect.Effect => { + if ( + context.packages.length !== TodoPackageIds.length || + !TodoPackageIds.every((id) => context.packages.some((target) => target.id === id)) + ) { + return Effect.fail(new Kernel.RenderFailure({ generatorId: "todo-surface-input", reason: "unsafe-path" })) + } + const exports = exportsFor(selectedWithPrerequisites(selected), context.entity.singular) + const dependencies = { + application: exports.application.length > 0 ? ["domain"] : [], + domain: [], + infrastructure: exports.infrastructure.length > 0 ? ["application", "domain"] : [], + presentation: exports.presentation.length > 0 ? ["infrastructure", "application", "domain"] : [], + } + return Effect.succeed({ + packages: TodoPackageIds.map((packageId) => ({ + dependencies: dependencies[packageId], + exports: exports[packageId].map((name) => ({ + from: `./${packageId === "domain" ? (name === `${context.entity.singular}Event` ? "event" : "model") : packageId === "application" ? (["Repository", "Clock", "IdGenerator", "Events"].some((suffix) => name === `${context.entity.singular}${suffix}`) ? "port" : "use-case") : packageId === "infrastructure" ? "adapter" : "presentation"}.js`, + name, + })), + packageId, + })), + }) +} diff --git a/packages/app-builder/generation/src/generators/use-case.ts b/packages/app-builder/generation/src/generators/use-case.ts index cb409060..9b1cd004 100644 --- a/packages/app-builder/generation/src/generators/use-case.ts +++ b/packages/app-builder/generation/src/generators/use-case.ts @@ -1,9 +1,16 @@ -import { defineTodoGenerationBlock } from "./block.js" +import { defineTodoGenerationBlock, fromTodoTemplate } from "./block.js" +import { defineTodoGenerator } from "./todo.js" + +export const useCaseGenerator = defineTodoGenerator({ + files: [{ relativePath: "src/use-case.ts", sourcePath: "__targetRoot__/src/use-case.ts.template" }], + id: "use-case", + packageId: "application", + provides: ["todo-use-case"], + requires: ["todo-port"], +}) export const useCaseBlock = defineTodoGenerationBlock({ - files: [ - { content: 'export { TodoApplication } from "./index.js"\n', path: "packages/todo/application/src/use-case.ts" }, - ], + files: [fromTodoTemplate("packages/todo/application/src/use-case.ts")], id: "use-case", manifestPath: "packages/todo/application/.effectify/generation/use-case.json", provides: ["todo.use-case"], diff --git a/packages/app-builder/generation/src/index.ts b/packages/app-builder/generation/src/index.ts index bb1c1bfc..793a4e04 100644 --- a/packages/app-builder/generation/src/index.ts +++ b/packages/app-builder/generation/src/index.ts @@ -1,10 +1,40 @@ export * as Catalog from "./catalog.js" +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" export * as TodoPreset from "./todo-preset.js" +export * as TodoV1 from "./todo-v1.js" +export * as Templates from "./templates.js" export type { TodoPlan } from "./planner.js" +export type { + AtomicGenerator, + Capability, + FileContribution, + FiniteCatalog, + GenerationFailure, + GeneratorId, + OwnerId, + PackageId, + PackageTarget, + RenderContext, + SafeRelativePath, + SourceDigest, + SurfaceId, +} from "./kernel.js" +export type { TemplateAsset, TemplateSubstitutions } from "./templates.js" +export type { CatalogComposition } from "./catalog.js" +export type { ReplayProvenance } from "./provenance.js" export type { TodoGenerationBlockId } from "./generators/index.js" -export type { TodoTopology, TodoTopologyFile, TodoTopologyProject, TodoTopologyRoot } from "./todo-preset.js" +export type { + TodoPackageRole, + TodoTopology, + TodoTopologyFile, + TodoTopologyProject, + TodoTopologyRoot, +} from "./todo-preset.js" diff --git a/packages/app-builder/generation/src/intent.ts b/packages/app-builder/generation/src/intent.ts index 15b45671..0b05c079 100644 --- a/packages/app-builder/generation/src/intent.ts +++ b/packages/app-builder/generation/src/intent.ts @@ -14,10 +14,21 @@ export const Capability = Schema.Literals([ ]) export type Capability = typeof Capability.Type +const Identifier = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9-]{0,62}$/)) +const TypeName = Schema.String.check(Schema.isPattern(/^[A-Z][A-Za-z0-9]{0,62}$/)) +const Naming = Schema.Struct({ + workspace: Identifier, + npmScope: Schema.String.check(Schema.isPattern(/^@[a-z][a-z0-9-]{0,62}$/)), + domain: Schema.Struct({ id: Identifier, name: TypeName }), + entity: Schema.Struct({ id: Identifier, singular: TypeName, plural: TypeName }), + entrypoint: Schema.Struct({ id: Identifier, name: TypeName }), +}) + export const CreationIntentSchema = Schema.Struct({ version: Schema.Literal("effectify.creation-intent/1"), preset: Schema.Literal("todo"), capabilities: Schema.Array(Capability), + naming: Schema.optionalKey(Naming), }) export type CreationIntent = typeof CreationIntentSchema.Type diff --git a/packages/app-builder/generation/src/kernel.ts b/packages/app-builder/generation/src/kernel.ts new file mode 100644 index 00000000..1255d6f3 --- /dev/null +++ b/packages/app-builder/generation/src/kernel.ts @@ -0,0 +1,132 @@ +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" +import type { TemplateAsset } from "./templates.js" +const IdentifierSchema = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9-]*$/)) +const TypeNameSchema = Schema.String.check(Schema.isPattern(/^[A-Z][A-Za-z0-9]{0,62}$/)) +const SafeRelativePathSchema = Schema.String.check( + Schema.isPattern(/^(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/), +) +const SourceDigestSchema = Schema.String.check(Schema.isPattern(/^sha256:[A-Za-z0-9._-]+$/)) +const ScopeSchema = Schema.String.check(Schema.isPattern(/^@[a-z][a-z0-9-]*$/)) +const PackageNameSchema = Schema.String.check(Schema.isPattern(/^@[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/)) +export const Identifier = IdentifierSchema.pipe(Schema.brand("Identifier")) +export const SafeRelativePath = SafeRelativePathSchema.pipe(Schema.brand("SafeRelativePath")) +export const SourceDigest = SourceDigestSchema.pipe(Schema.brand("SourceDigest")) +const CapabilitySchema = IdentifierSchema.pipe(Schema.brand("Capability")) +export type Identifier = Schema.Schema.Type +export type GeneratorId = Identifier +export type OwnerId = Identifier +export type PackageId = Identifier +export type SafeRelativePath = Schema.Schema.Type +export type SourceDigest = Schema.Schema.Type +export type SurfaceId = Identifier +export type Capability = Schema.Schema.Type & Name +export const capability = (value: Name): Capability => + Schema.decodeUnknownSync(CapabilitySchema)(value) as Capability +export const capabilities = (...values: Names) => + values.map(capability) as { readonly [Index in keyof Names]: Capability> } +export const identifier = (value: unknown): Identifier => Schema.decodeUnknownSync(Identifier)(value) +export const safeRelativePath = (value: unknown): SafeRelativePath => Schema.decodeUnknownSync(SafeRelativePath)(value) +export const sourceDigest = (value: unknown): SourceDigest => Schema.decodeUnknownSync(SourceDigest)(value) +export interface FileContribution { + readonly bytes: Uint8Array + readonly mode: string + readonly owner: OwnerId + readonly package: PackageId | "workspace" + readonly path: SafeRelativePath + readonly sourceDigest: SourceDigest + readonly surface: SurfaceId + readonly template?: TemplateAsset +} +export interface AtomicGenerator< + Input, + Provides extends Capability = Capability, + Requires extends Capability = Capability, +> { + readonly InputSchema: Schema.ConstraintDecoder + readonly id: GeneratorId + readonly provides: ReadonlyArray + readonly requires: ReadonlyArray + readonly version: string + render(input: Input, context: RenderContext): Effect.Effect, GenerationFailure> +} +export const atomicGenerator = ( + generator: AtomicGenerator, +): AtomicGenerator => + Object.freeze({ + ...generator, + provides: Object.freeze([...generator.provides]), + requires: Object.freeze([...generator.requires]), + }) +type CatalogGenerator = AtomicGenerator +type CatalogTuple = readonly [CatalogGenerator, ...CatalogGenerator[]] +type ProvidesOf = + Generators[number] extends AtomicGenerator ? Provides : never +type RequiresOf = + Generators[number] extends AtomicGenerator ? Requires : never +declare const FiniteCatalogTypeId: unique symbol +export type FiniteCatalog = Readonly & { + readonly [FiniteCatalogTypeId]: "effectify.finite-catalog/1" +} +export const defineCatalog = ( + generators: Generators & (Exclude, ProvidesOf> extends never ? unknown : never), +): FiniteCatalog => Object.freeze(generators.map(atomicGenerator)) as FiniteCatalog +export class SchemaContextFailure extends Schema.TaggedErrorClass()("SchemaContextFailure", { + boundary: Schema.Literals(["intent", "context"]), + reason: Schema.Literals(["schema", "derived-identity"]), +}) {} +export class CapabilityGraphFailure extends Schema.TaggedErrorClass()( + "CapabilityGraphFailure", + { + capability: Schema.String, + reason: Schema.Literals(["missing-capability", "cyclic-capability", "duplicate-generator-id"]), + }, +) {} +export class ContributionConflict extends Schema.TaggedErrorClass()("ContributionConflict", { + identity: Schema.String, + reason: Schema.Literals(["duplicate-provider", "duplicate-owner", "duplicate-path"]), +}) {} +export class RenderFailure extends Schema.TaggedErrorClass()("RenderFailure", { + generatorId: Schema.String, + path: Schema.optionalKey(Schema.String), + reason: Schema.Literals(["nondeterministic-render", "unsafe-path"]), +}) {} +export const PackageTargetSchema = Schema.Struct({ id: Identifier, name: PackageNameSchema, root: SafeRelativePath }) +export type PackageTarget = Schema.Schema.Type +export const RenderContextSchema = Schema.Struct({ + version: Schema.Literal("effectify.render-context/1"), + workspace: Schema.Struct({ name: IdentifierSchema, npmScope: ScopeSchema }), + domain: Schema.Struct({ id: IdentifierSchema, name: TypeNameSchema, importName: PackageNameSchema }), + entity: Schema.Struct({ + id: IdentifierSchema, + singular: TypeNameSchema, + plural: TypeNameSchema, + }), + entrypoint: Schema.Struct({ id: IdentifierSchema, name: TypeNameSchema, importName: PackageNameSchema }), + packages: Schema.Array(PackageTargetSchema), +}) +export type RenderContext = Schema.Schema.Type +const unique = (values: ReadonlyArray): boolean => new Set(values).size === values.length +const validate = (context: RenderContext): Effect.Effect => { + const packages = context.packages + const names = new Set(packages.map((target) => target.name)) + const valid = + packages.length > 0 && + unique(packages.map((target) => target.id)) && + unique(packages.map((target) => target.name)) && + unique(packages.map((target) => target.root)) && + packages.every((target) => target.name.startsWith(`${context.workspace.npmScope}/`)) && + names.has(context.domain.importName) && + names.has(context.entrypoint.importName) + return valid + ? Effect.succeed( + Object.freeze({ ...context, packages: Object.freeze(packages.map((target) => Object.freeze({ ...target }))) }), + ) + : Effect.fail(new SchemaContextFailure({ boundary: "context", reason: "derived-identity" })) +} +export const decodeRenderContext = (input: unknown): Effect.Effect => + Schema.decodeUnknownEffect(RenderContextSchema, { onExcessProperty: "error" })(input).pipe( + Effect.mapError(() => new SchemaContextFailure({ boundary: "context", reason: "schema" })), + Effect.flatMap(validate), + ) +export type GenerationFailure = SchemaContextFailure | CapabilityGraphFailure | ContributionConflict | RenderFailure diff --git a/packages/app-builder/generation/src/planner.ts b/packages/app-builder/generation/src/planner.ts index 965f8334..4b519385 100644 --- a/packages/app-builder/generation/src/planner.ts +++ b/packages/app-builder/generation/src/planner.ts @@ -43,11 +43,22 @@ const selectedPlugin = (entry: CatalogEntry): SelectedPlugin | undefined => } : undefined -const canonicalIntent = (intent: CreationIntent): CreationIntent => ({ - capabilities: [...new Set(intent.capabilities)].sort(compareCapability), - preset: intent.preset, - version: intent.version, -}) +const canonicalIntent = (intent: CreationIntent): CreationIntent => + Object.freeze({ + capabilities: Object.freeze([...new Set(intent.capabilities)].sort(compareCapability)), + ...(intent.naming === undefined + ? {} + : { + naming: Object.freeze({ + ...intent.naming, + domain: Object.freeze(intent.naming.domain), + entity: Object.freeze(intent.naming.entity), + entrypoint: Object.freeze(intent.naming.entrypoint), + }), + }), + preset: intent.preset, + version: intent.version, + }) /** Produces the versioned Todo plan before any Nx adapter or workspace mutation exists. */ export const planTodo = (input: unknown): Effect.Effect => diff --git a/packages/app-builder/generation/src/provenance.ts b/packages/app-builder/generation/src/provenance.ts new file mode 100644 index 00000000..1ee8a4bc --- /dev/null +++ b/packages/app-builder/generation/src/provenance.ts @@ -0,0 +1,201 @@ +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Cjson from "./cjson.js" + +export const ReplayIdentities = ["blocks", "catalog", "dependencies", "intent", "outputs", "pins", "plan"] as const +export type ReplayIdentity = (typeof ReplayIdentities)[number] + +export interface ReplayEvidence { + readonly blocks: string + readonly catalog: string + readonly dependencies: string + readonly intent: string + readonly outputs: string + readonly pins: string + readonly plan: string +} + +export interface OutputIdentity { + readonly mode: string + readonly owner: string + readonly path: string + readonly sourceDigest: string +} + +export interface ReplayProvenance { + readonly canonicalJson: typeof Cjson.CanonicalJsonAlgorithm + readonly evidence: ReplayEvidence + readonly outputIdentities: ReadonlyArray + readonly provenanceDigest: string + readonly version: "effectify.app-builder-replay-provenance/1" +} + +export class ReplayProvenanceError extends Data.TaggedError("ReplayProvenanceError")<{ + readonly field: string + readonly reason: "invalid-candidate" | "unfrozen-install" +}> {} + +interface ReplayCandidate { + readonly blocks: unknown + readonly catalog: unknown + readonly dependencies: unknown + readonly intent: unknown + readonly outputs: unknown + readonly pins: unknown + readonly plan: unknown +} + +interface ReplayOutput { + readonly content: string + readonly mode: string + readonly owner: string + readonly path: string +} + +interface SemanticDependency { + readonly importer: string + readonly integrity: string + readonly name: string + readonly peers: Readonly> + readonly version: string +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const text = (record: Record, field: string): string | undefined => { + const value = record[field] + return typeof value === "string" && value.length > 0 ? value : undefined +} + +const failure = (field: string, reason: ReplayProvenanceError["reason"] = "invalid-candidate") => + Effect.fail(new ReplayProvenanceError({ field, reason })) + +const decodeCandidate = (input: unknown): Effect.Effect => { + if (!isRecord(input)) return failure("candidate") + const { blocks, catalog, dependencies, intent, outputs, pins, plan } = input + return blocks === undefined || + catalog === undefined || + dependencies === undefined || + intent === undefined || + outputs === undefined || + pins === undefined || + plan === undefined + ? failure("candidate") + : Effect.succeed({ blocks, catalog, dependencies, intent, outputs, pins, plan }) +} + +const decodeOutputs = (input: unknown): Effect.Effect, ReplayProvenanceError> => { + if (!Array.isArray(input)) return failure("outputs") + const outputs: Array = [] + for (const value of input) { + if (!isRecord(value)) return failure("outputs") + const content = text(value, "content") + const mode = text(value, "mode") + const owner = text(value, "owner") + const path = text(value, "path") + if ( + content === undefined || + mode === undefined || + owner === undefined || + path === undefined || + path.includes("..") + ) { + return failure("outputs") + } + outputs.push({ content: Cjson.normalizeSource(content), mode, owner, path }) + } + const ordered = outputs.sort((left, right) => left.path.localeCompare(right.path)) + return ordered.length === 0 || ordered.some((output, index) => output.path === ordered[index - 1]?.path) + ? failure("outputs") + : Effect.succeed(Object.freeze(ordered)) +} + +const semanticDependencies = ( + input: unknown, +): Effect.Effect, ReplayProvenanceError> => { + if (!Array.isArray(input)) return failure("dependencies") + const dependencies: Array = [] + for (const value of input) { + if (!isRecord(value) || !isRecord(value.peers)) return failure("dependencies") + const importer = text(value, "importer") + const integrity = text(value, "integrity") + const name = text(value, "name") + const version = text(value, "version") + const peers: Record = {} + for (const [key, peer] of Object.entries(value.peers).sort()) { + if (typeof peer !== "string") return failure("dependencies") + peers[key] = peer + } + if (importer === undefined || integrity === undefined || name === undefined || version === undefined) + return failure("dependencies") + dependencies.push({ importer, integrity, name, peers: Object.freeze(peers), version }) + } + return Effect.succeed( + Object.freeze(dependencies.sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)))), + ) +} + +const validatePins = (input: unknown): Effect.Effect => { + if ( + !isRecord(input) || + text(input, "packageManager") === undefined || + text(input, "nx") === undefined || + text(input, "effect") === undefined || + !Array.isArray(input.plugins) + ) + return failure("pins") + if (input.frozenInstall !== true) return failure("pins", "unfrozen-install") + return input.plugins.every( + (plugin) => isRecord(plugin) && text(plugin, "name") !== undefined && text(plugin, "version") !== undefined, + ) + ? Effect.void + : failure("pins") +} + +const digest = (identity: ReplayIdentity, input: unknown): Effect.Effect => + Cjson.canonicalDigest(input).pipe( + Effect.mapError(() => new ReplayProvenanceError({ field: identity, reason: "invalid-candidate" })), + ) + +export const semanticDependencyDigest = (input: unknown): Effect.Effect => + Effect.flatMap(semanticDependencies(input), (dependencies) => digest("dependencies", dependencies)) + +export const outputIdentityDigest = ( + outputIdentities: ReadonlyArray, +): Effect.Effect => digest("outputs", outputIdentities) + +/** Captures normalized, frozen semantic provenance without acquiring a mutation adapter. */ +export const captureReplayProvenance = (input: unknown): Effect.Effect => + Effect.gen(function* () { + const candidate = yield* decodeCandidate(input) + yield* validatePins(candidate.pins) + const outputs = yield* decodeOutputs(candidate.outputs) + const outputIdentities = Object.freeze( + outputs.map(({ content, mode, owner, path }) => + Object.freeze({ mode, owner, path, sourceDigest: Cjson.canonicalSourceDigest(content) }), + ), + ) + const evidence: ReplayEvidence = Object.freeze({ + blocks: yield* digest("blocks", candidate.blocks), + catalog: yield* digest("catalog", candidate.catalog), + dependencies: yield* semanticDependencyDigest(candidate.dependencies), + intent: yield* digest("intent", candidate.intent), + outputs: yield* outputIdentityDigest(outputIdentities), + pins: yield* digest("pins", candidate.pins), + plan: yield* digest("plan", candidate.plan), + }) + const provenanceDigest = yield* Cjson.canonicalDigest({ + canonicalJson: Cjson.CanonicalJsonAlgorithm, + evidence, + outputIdentities, + version: "effectify.app-builder-replay-provenance/1", + }).pipe(Effect.mapError(() => new ReplayProvenanceError({ field: "provenance", reason: "invalid-candidate" }))) + return Object.freeze({ + canonicalJson: Cjson.CanonicalJsonAlgorithm, + evidence, + outputIdentities, + provenanceDigest, + version: "effectify.app-builder-replay-provenance/1", + }) + }) 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/src/replay.ts b/packages/app-builder/generation/src/replay.ts new file mode 100644 index 00000000..389b7288 --- /dev/null +++ b/packages/app-builder/generation/src/replay.ts @@ -0,0 +1,166 @@ +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Cjson from "./cjson.js" +import { + captureReplayProvenance, + outputIdentityDigest, + ReplayIdentities, + type ReplayIdentity, + type ReplayProvenance, +} from "./provenance.js" + +export class ReplayEvidenceError extends Data.TaggedError("ReplayEvidenceError")<{ + readonly identity: ReplayIdentity | "provenance" + readonly phase: "candidate" | "recorded" | "workspace" + readonly reason: "invalid-input" | "mismatch" +}> {} + +export interface ReplayWorkspace { + readonly readOutput: (path: string) => string | undefined +} + +export interface ReplayResult { + readonly diffPaths: ReadonlyArray + readonly zeroDiff: true +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const hasOutputIdentities = (value: unknown): value is ReadonlyArray => { + if (!Array.isArray(value) || value.length === 0) return false + for (const output of value) { + if ( + !isRecord(output) || + typeof output.path !== "string" || + output.path.length === 0 || + typeof output.mode !== "string" || + output.mode.length === 0 || + typeof output.owner !== "string" || + output.owner.length === 0 || + typeof output.sourceDigest !== "string" || + output.sourceDigest.length === 0 || + output.path.includes("..") + ) { + return false + } + } + return true +} + +const isProvenance = (value: unknown): value is ReplayProvenance => { + if (!isRecord(value)) return false + const evidence = value.evidence + const outputIdentities = value.outputIdentities + return ( + value.version === "effectify.app-builder-replay-provenance/1" && + value.canonicalJson === Cjson.CanonicalJsonAlgorithm && + typeof value.provenanceDigest === "string" && + isRecord(evidence) && + ReplayIdentities.every((identity) => typeof evidence[identity] === "string") && + hasOutputIdentities(outputIdentities) + ) +} + +const mismatch = ( + identity: ReplayIdentity | "provenance", + phase: ReplayEvidenceError["phase"], + reason: ReplayEvidenceError["reason"] = "mismatch", +) => Effect.fail(new ReplayEvidenceError({ identity, phase, reason })) + +const validateRecordedProvenance = (provenance: ReplayProvenance): Effect.Effect => + Effect.gen(function* () { + if (!hasOutputIdentities(provenance.outputIdentities)) { + return yield* mismatch("provenance", "recorded", "invalid-input") + } + const outputsDigest = yield* outputIdentityDigest(provenance.outputIdentities).pipe( + Effect.mapError( + () => new ReplayEvidenceError({ identity: "outputs", phase: "recorded", reason: "invalid-input" }), + ), + ) + if (outputsDigest !== provenance.evidence.outputs) { + return yield* mismatch("outputs", "recorded") + } + const digest = yield* Cjson.canonicalDigest({ + canonicalJson: provenance.canonicalJson, + evidence: provenance.evidence, + outputIdentities: provenance.outputIdentities, + version: provenance.version, + }).pipe( + Effect.mapError( + () => new ReplayEvidenceError({ identity: "provenance", phase: "recorded", reason: "invalid-input" }), + ), + ) + if (digest !== provenance.provenanceDigest) return yield* mismatch("provenance", "recorded") + }) + +const hasExactOutputIdentityBinding = (left: ReplayProvenance, right: ReplayProvenance): boolean => + left.outputIdentities.length === right.outputIdentities.length && + left.outputIdentities.every((identity, index) => { + const candidate = right.outputIdentities[index] + return ( + candidate !== undefined && + identity.path === candidate.path && + identity.mode === candidate.mode && + identity.owner === candidate.owner && + identity.sourceDigest === candidate.sourceDigest + ) + }) + +/** Validates candidate provenance before observing output, so this authority cannot write a Tree. */ +export const validateReplay = ( + provenance: ReplayProvenance, + candidate: unknown, + workspace: ReplayWorkspace, +): Effect.Effect => + Effect.gen(function* () { + yield* validateRecordedProvenance(provenance) + const candidateProvenance = yield* captureReplayProvenance(candidate).pipe( + Effect.mapError( + () => new ReplayEvidenceError({ identity: "provenance", phase: "candidate", reason: "invalid-input" }), + ), + ) + if (!hasExactOutputIdentityBinding(provenance, candidateProvenance)) { + return yield* mismatch("outputs", "candidate") + } + for (const identity of ReplayIdentities) { + if (candidateProvenance.evidence[identity] !== provenance.evidence[identity]) + return yield* mismatch(identity, "candidate") + } + for (const output of provenance.outputIdentities) { + const source = workspace.readOutput(output.path) + if (source === undefined || Cjson.canonicalSourceDigest(source) !== output.sourceDigest) { + return yield* mismatch("outputs", "workspace") + } + } + return Object.freeze({ diffPaths: Object.freeze([]), zeroDiff: true }) + }) + +const workspaceFrom = (input: unknown): Effect.Effect => { + if (!Array.isArray(input)) return mismatch("provenance", "workspace", "invalid-input") + const outputs = new Map() + for (const value of input) { + if ( + !isRecord(value) || + typeof value.path !== "string" || + typeof value.content !== "string" || + outputs.has(value.path) + ) { + return mismatch("provenance", "workspace", "invalid-input") + } + outputs.set(value.path, value.content) + } + return Effect.succeed({ readOutput: (path) => outputs.get(path) }) +} + +/** Decodes the closed CLI replay payload into the same no-mutation replay authority. */ +export const validateReplayPayload = (input: unknown): Effect.Effect => { + if (!isRecord(input)) return mismatch("provenance", "recorded", "invalid-input") + const provenance = input.provenance + if (!isProvenance(provenance)) return mismatch("provenance", "recorded", "invalid-input") + return Effect.flatMap(workspaceFrom(input.workspaceOutputs), (workspace) => + validateReplay(provenance, input.candidate, workspace), + ) +} + +export { captureReplayProvenance } from "./provenance.js" diff --git a/packages/app-builder/generation/src/templates.ts b/packages/app-builder/generation/src/templates.ts new file mode 100644 index 00000000..2b8e0663 --- /dev/null +++ b/packages/app-builder/generation/src/templates.ts @@ -0,0 +1,110 @@ +import { readFileSync } from "node:fs" +import { isAbsolute, join, relative } from "node:path" +import { fileURLToPath } from "node:url" +import ejs from "ejs" + +export interface TemplateSubstitutions { + readonly tmpl: "" + readonly [name: string]: string +} + +export interface TemplateAsset { + readonly directory: string + readonly group: string + readonly sourcePath: string + readonly substitutions: TemplateSubstitutions +} + +const root = fileURLToPath(new URL("./templates/assets/", import.meta.url)) +const safe = (value: string): boolean => + value.length > 0 && + !isAbsolute(value) && + !value.includes("\\") && + value.split("/").every((segment) => ![".", ".."].includes(segment) && /^[A-Za-z0-9_.-]+$/.test(segment)) + +export const templateRoot = (): string => root + +type TemplateContext = Readonly<{ + workspace: { name: string; npmScope: string } + entity: { id: string; singular: string; plural: string } + packages: ReadonlyArray<{ id: string; name: string; root: string }> +}> +export const templateSubstitutions = ( + context: TemplateContext, + overrides: Readonly> = {}, +): TemplateSubstitutions => { + const targets = Object.fromEntries(context.packages.map((target) => [target.id, target])) + const domain = targets.domain ?? context.packages[0]! + const application = targets.application ?? domain + const infrastructure = targets.infrastructure ?? application + const presentation = targets.presentation ?? application + const roots = [...new Set(context.packages.map((target) => target.root.split("/")[0]))].sort() + const globs = [...new Set(context.packages.map(({ root }) => root.replace(/\/[^/]+/g, "/*")))].sort() + const typeName = (value: string) => `${value[0]?.toLowerCase()}${value.slice(1)}` + return Object.freeze({ + applicationImport: application.name, + brandNamespace: context.workspace.npmScope.slice(1).replace(/(^|-)(\w)/g, (_, _dash, char) => char.toUpperCase()), + domainImport: domain.name, + entityId: context.entity.id, + entityIdentifier: typeName(context.entity.singular), + entityPlural: context.entity.plural, + entityPluralIdentifier: typeName(context.entity.plural), + entitySingular: context.entity.singular, + infrastructureImport: infrastructure.name, + presentationImport: presentation.name, + workspacePackageGlobs: globs.map((path) => ` - ${path}`).join("\n"), + workspacePackageName: `${context.workspace.npmScope}/${context.workspace.name}`, + workspaceScope: context.workspace.npmScope, + workspaceSourceIncludes: roots.map((path) => ` "${path}/**/src/**/*.ts"`).join(",\n"), + workspaceTestIncludes: roots.map((path) => `"${path}/**/tests/**/*.test.ts"`).join(", "), + ...overrides, + tmpl: "", + }) +} + +export const templateDirectory = (group: Pick): string => { + if (!safe(group.directory)) throw new Error(`Unsafe template directory: ${group.directory}`) + const directory = join(root, group.directory) + if (relative(root, directory).startsWith("..")) throw new Error(`Template directory escapes package: ${directory}`) + return directory +} + +export const templateAsset = (options: TemplateAsset): TemplateAsset => { + if (![options.directory, options.group, options.sourcePath].every(safe)) { + throw new Error(`Unsafe template asset: ${options.sourcePath}`) + } + return Object.freeze({ + ...options, + substitutions: Object.freeze({ ...options.substitutions }), + }) +} + +/** + * Renders synchronously with the same EJS semantics consumed by Nx generateFiles. + * EJS executes JavaScript, so only validated package-owned templates may reach this boundary; substitutions are bounded immutable data. + */ +export const renderTemplate = (asset: TemplateAsset): string => { + const source = join(templateDirectory(asset), asset.sourcePath) + return ejs.render(readFileSync(source, "utf8"), asset.substitutions, { + filename: source, + }) +} + +export const templateGroups = ( + contributions: ReadonlyArray<{ readonly template?: TemplateAsset }>, +): ReadonlyArray => { + const groups = new Map() + for (const contribution of contributions) { + const asset = contribution.template + if (asset === undefined) continue + const existing = groups.get(asset.group) + if ( + existing !== undefined && + (existing.directory !== asset.directory || + JSON.stringify(existing.substitutions) !== JSON.stringify(asset.substitutions)) + ) + throw new Error(`Inconsistent template group: ${asset.group}`) + groups.set(asset.group, asset) + } + return Object.freeze([...groups.values()].sort((left, right) => left.group.localeCompare(right.group))) +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/package.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/package.json.template new file mode 100644 index 00000000..610161f9 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/package.json.template @@ -0,0 +1,16 @@ +{ + "name": "<%- domainImport %>", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/events.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/events.ts.template new file mode 100644 index 00000000..b1ec0bd4 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/events.ts.template @@ -0,0 +1 @@ +export type { <%- entitySingular %>Event } from "./index.js" diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/index.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/index.ts.template new file mode 100644 index 00000000..00bd7532 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/src/index.ts.template @@ -0,0 +1,28 @@ +import * as Data from "effect/Data" +import * as Schema from "effect/Schema" + +export const <%- entitySingular %>Id = Schema.NonEmptyString.pipe(Schema.brand("<%- brandNamespace %>.<%- entitySingular %>Id")) +export type <%- entitySingular %>Id = typeof <%- entitySingular %>Id.Type + +export const <%- entitySingular %> = Schema.Struct({ + completed: Schema.Boolean, + createdAt: Schema.String, + id: <%- entitySingular %>Id, + text: Schema.NonEmptyString, +}) +export type <%- entitySingular %> = typeof <%- entitySingular %>.Type + +export class <%- entitySingular %>TextInvalid extends Data.TaggedError("<%- entitySingular %>TextInvalid")<{ readonly reason: "empty" }> {} +export class <%- entitySingular %>NotFound extends Data.TaggedError("<%- entitySingular %>NotFound")<{ readonly id: string }> {} +export class <%- entitySingular %>AlreadyCompleted extends Data.TaggedError("<%- entitySingular %>AlreadyCompleted")<{ readonly id: string }> {} +export class <%- entitySingular %>IdExhausted extends Data.TaggedError("<%- entitySingular %>IdExhausted")<{}> {} +export class <%- entitySingular %>PersistenceError extends Data.TaggedError("<%- entitySingular %>PersistenceError")<{ + readonly operation: "read" | "write" +}> {} + +export type <%- entitySingular %>Event = Data.TaggedEnum<{ + <%- entitySingular %>Added: { readonly <%- entityIdentifier %>: <%- entitySingular %> } + <%- entitySingular %>Completed: { readonly <%- entityIdentifier %>: <%- entitySingular %> } + <%- entitySingular %>Removed: { readonly <%- entityIdentifier %>: <%- entitySingular %> } +}> +export const <%- entitySingular %>Event = Data.taggedEnum<<%- entitySingular %>Event>() diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/tests/__entityId__.test.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/tests/__entityId__.test.ts.template new file mode 100644 index 00000000..03dd4dc2 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-1/__targetRoot__/tests/__entityId__.test.ts.template @@ -0,0 +1,7 @@ +import { expect, it } from "vitest" +import * as Schema from "effect/Schema" +import { <%- entitySingular %> } from "../src/index.js" + +it("decodes a <%- entitySingular %> schema", () => { + expect(Schema.is(<%- entitySingular %>)({ completed: false, createdAt: "2026-01-01T00:00:00.000Z", id: "<%- entityId %>-1", text: "write tests" })).toBe(true) +}) diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/package.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/package.json.template new file mode 100644 index 00000000..63e14780 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/package.json.template @@ -0,0 +1,17 @@ +{ + "name": "<%- applicationImport %>", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:", + "<%- domainImport %>": "workspace:*" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/index.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/index.ts.template new file mode 100644 index 00000000..51d433fe --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/index.ts.template @@ -0,0 +1,67 @@ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { <%- entitySingular %>Event, <%- entitySingular %>Id, type <%- entitySingular %>, type <%- entitySingular %>Event as Event, <%- entitySingular %>AlreadyCompleted, <%- entitySingular %>IdExhausted, <%- entitySingular %>NotFound, <%- entitySingular %>PersistenceError, <%- entitySingular %>TextInvalid } from "<%- domainImport %>" + +export interface <%- entitySingular %>RepositoryApi { + readonly find: (id: <%- entitySingular %>Id) => Effect.Effect<<%- entitySingular %> | undefined, <%- entitySingular %>PersistenceError> + readonly list: () => Effect.Effect>, <%- entitySingular %>PersistenceError> + readonly remove: (id: <%- entitySingular %>Id) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>PersistenceError> + readonly save: (<%- entityIdentifier %>: <%- entitySingular %>) => Effect.EffectPersistenceError> +} +export class <%- entitySingular %>Repository extends Context.Service<<%- entitySingular %>Repository, <%- entitySingular %>RepositoryApi>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>Repository") {} + +export class <%- entitySingular %>Clock extends Context.Service<<%- entitySingular %>Clock, { readonly now: () => Effect.Effect }>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>Clock") {} +export class <%- entitySingular %>IdGenerator extends Context.Service<<%- entitySingular %>IdGenerator, { readonly next: () => Effect.Effect<<%- entitySingular %>Id, <%- entitySingular %>IdExhausted> }>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>IdGenerator") {} +export class <%- entitySingular %>Events extends Context.Service<<%- entitySingular %>Events, { readonly publish: (event: Event) => Effect.Effect }>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>Events") {} + +type <%- entitySingular %>Failure = <%- entitySingular %>AlreadyCompleted | <%- entitySingular %>IdExhausted | <%- entitySingular %>NotFound | <%- entitySingular %>PersistenceError | <%- entitySingular %>TextInvalid + +export interface <%- entitySingular %>ApplicationApi { + readonly add: (text: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> + readonly complete: (id: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> + readonly list: () => Effect.Effect>, <%- entitySingular %>PersistenceError> + readonly remove: (id: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> +} +export class <%- entitySingular %>Application extends Context.Service<<%- entitySingular %>Application, <%- entitySingular %>ApplicationApi>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>Application") {} + +const resolveId = (id: string): Effect.Effect<<%- entitySingular %>Id, <%- entitySingular %>NotFound> => + Schema.decodeUnknownEffect(<%- entitySingular %>Id)(id).pipe(Effect.mapError(() => new <%- entitySingular %>NotFound({ id }))) + +export const layer = Layer.effect(<%- entitySingular %>Application, Effect.gen(function* () { + const repository = yield* <%- entitySingular %>Repository + const clock = yield* <%- entitySingular %>Clock + const ids = yield* <%- entitySingular %>IdGenerator + const events = yield* <%- entitySingular %>Events + const find = Effect.fn("<%- entitySingular %>Application.find")(function* (rawId: string) { + const id = yield* resolveId(rawId) + const <%- entityIdentifier %> = Option.fromUndefinedOr(yield* repository.find(id)) + if (Option.isNone(<%- entityIdentifier %>)) return yield* Effect.fail(new <%- entitySingular %>NotFound({ id: rawId })) + return <%- entityIdentifier %>.value + }) + const add = Effect.fn("<%- entitySingular %>Application.add")(function* (text: string) { + const normalized = text.trim() + if (normalized.length === 0) return yield* Effect.fail(new <%- entitySingular %>TextInvalid({ reason: "empty" })) + const <%- entityIdentifier %>: <%- entitySingular %> = { completed: false, createdAt: yield* clock.now(), id: yield* ids.next(), text: normalized } + yield* repository.save(<%- entityIdentifier %>) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Added({ <%- entityIdentifier %> })) + return <%- entityIdentifier %> + }) + const complete = Effect.fn("<%- entitySingular %>Application.complete")(function* (id: string) { + const <%- entityIdentifier %> = yield* find(id) + if (<%- entityIdentifier %>.completed) return yield* Effect.fail(new <%- entitySingular %>AlreadyCompleted({ id })) + const completed = { ...<%- entityIdentifier %>, completed: true } + yield* repository.save(completed) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Completed({ <%- entityIdentifier %>: completed })) + return completed + }) + const remove = Effect.fn("<%- entitySingular %>Application.remove")(function* (id: string) { + const <%- entityIdentifier %> = yield* find(id) + yield* repository.remove(<%- entityIdentifier %>.id) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Removed({ <%- entityIdentifier %> })) + return <%- entityIdentifier %> + }) + return <%- entitySingular %>Application.of({ add, complete, list: repository.list, remove }) +})) diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/use-case.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/use-case.ts.template new file mode 100644 index 00000000..efe2c2dc --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-2/__targetRoot__/src/use-case.ts.template @@ -0,0 +1 @@ +export { <%- entitySingular %>Application } from "./index.js" diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/package.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/package.json.template new file mode 100644 index 00000000..c436eaa1 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/package.json.template @@ -0,0 +1,18 @@ +{ + "name": "<%- infrastructureImport %>", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:", + "<%- applicationImport %>": "workspace:*", + "<%- domainImport %>": "workspace:*" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/src/index.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/src/index.ts.template new file mode 100644 index 00000000..3d636421 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/src/index.ts.template @@ -0,0 +1,91 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises" +import { randomUUID } from "node:crypto" +import { dirname } from "node:path" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as PubSub from "effect/PubSub" +import * as Ref from "effect/Ref" +import * as Stream from "effect/Stream" +import * as Schema from "effect/Schema" +import * as Application from "<%- applicationImport %>" +import { <%- entitySingular %>, <%- entitySingular %>Id, <%- entitySingular %>IdExhausted, <%- entitySingular %>PersistenceError, type <%- entitySingular %> as <%- entitySingular %>Model, type <%- entitySingular %>Event as Event } from "<%- domainImport %>" + +export interface <%- entitySingular %>TestProbeApi { readonly events: () => Effect.Effect> } +export class <%- entitySingular %>TestProbe extends Context.Service<<%- entitySingular %>TestProbe, <%- entitySingular %>TestProbeApi>()("<%- workspaceScope %>/<%- entityIdentifier %>/<%- entitySingular %>TestProbe") {} + +const decode<%- entityPlural %> = Schema.decodeUnknownEffect(Schema.Array(<%- entitySingular %>)) +const events = Effect.gen(function* () { + const visible = yield* Ref.make>([]) + const published = yield* PubSub.unbounded() + return { + probe: <%- entitySingular %>TestProbe.of({ events: () => Ref.get(visible) }), + service: Application.<%- entitySingular %>Events.of({ + publish: (event) => Ref.update(visible, (current) => [...current, event]).pipe( + Effect.andThen(PubSub.publish(published, event)), + Effect.asVoid, + ), + }), + stream: Stream.fromPubSub(published), + } +}) + +const repository = (read: () => Effect.EffectModel>, <%- entitySingular %>PersistenceError>, write: (<%- entityPluralIdentifier %>: ReadonlyArray<<%- entitySingular %>Model>) => Effect.EffectPersistenceError>) => + Application.<%- entitySingular %>Repository.of({ + find: (id) => read().pipe(Effect.map((<%- entityPluralIdentifier %>) => <%- entityPluralIdentifier %>.find((<%- entityIdentifier %>) => <%- entityIdentifier %>.id === id))), + list: read, + remove: (id) => Effect.gen(function* () { + const <%- entityPluralIdentifier %> = yield* read() + const <%- entityIdentifier %> = <%- entityPluralIdentifier %>.find((candidate) => candidate.id === id) + if (<%- entityIdentifier %> === undefined) return yield* Effect.fail(new <%- entitySingular %>PersistenceError({ operation: "write" })) + yield* write(<%- entityPluralIdentifier %>.filter((candidate) => candidate.id !== id)) + return <%- entityIdentifier %> + }), + save: (<%- entityIdentifier %>) => read().pipe( + Effect.flatMap((<%- entityPluralIdentifier %>) => write([...<%- entityPluralIdentifier %>.filter((candidate) => candidate.id !== <%- entityIdentifier %>.id), <%- entityIdentifier %>])), + ), + }) + +const fixedIds = (ids: ReadonlyArray) => Effect.gen(function* () { + const remaining = yield* Ref.make(ids) + return Application.<%- entitySingular %>IdGenerator.of({ next: () => Effect.gen(function* () { + const [next, ...rest] = yield* Ref.get(remaining) + if (next === undefined) return yield* Effect.fail(new <%- entitySingular %>IdExhausted()) + yield* Ref.set(remaining, rest) + return yield* Schema.decodeUnknownEffect(<%- entitySingular %>Id)(next).pipe(Effect.mapError(() => new <%- entitySingular %>IdExhausted())) + }) }) +}) + +export const testLayer = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Layer.effectContext(Effect.gen(function* () { + const <%- entityPluralIdentifier %> = yield* Ref.makeModel>>([]) + const eventServices = yield* events + return Context.empty().pipe( + Context.add(Application.<%- entitySingular %>Repository, repository(() => Ref.get(<%- entityPluralIdentifier %>), (next) => Ref.set(<%- entityPluralIdentifier %>, next))), + Context.add(Application.<%- entitySingular %>Clock, Application.<%- entitySingular %>Clock.of({ now: () => Effect.succeed(input.now) })), + Context.add(Application.<%- entitySingular %>IdGenerator, yield* fixedIds(input.ids)), + Context.add(Application.<%- entitySingular %>Events, eventServices.service), + Context.add(<%- entitySingular %>TestProbe, eventServices.probe), + ) +})) + +export const liveLayer = (path: string) => Layer.effectContext(Effect.gen(function* () { + yield* Effect.tryPromise({ try: () => mkdir(dirname(path), { recursive: true }), catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }) }) + const exists = yield* Effect.promise(() => access(path).then(() => true, () => false)) + if (!exists) yield* Effect.tryPromise({ try: () => writeFile(path, "[]\n"), catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }) }) + const read = () => Effect.tryPromise({ try: () => readFile(path, "utf8"), catch: () => new <%- entitySingular %>PersistenceError({ operation: "read" }) }).pipe( + Effect.flatMap((source) => Effect.try({ try: () => JSON.parse(source), catch: () => new <%- entitySingular %>PersistenceError({ operation: "read" }) })), + Effect.flatMap(decode<%- entityPlural %>), + Effect.mapError(() => new <%- entitySingular %>PersistenceError({ operation: "read" })), + ) + const write = (<%- entityPluralIdentifier %>: ReadonlyArray<<%- entitySingular %>Model>) => Effect.tryPromise({ + try: () => writeFile(path, JSON.stringify(<%- entityPluralIdentifier %>, null, 2) + "\n"), + catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }), + }) + const eventServices = yield* events + return Context.empty().pipe( + Context.add(Application.<%- entitySingular %>Repository, repository(read, write)), + Context.add(Application.<%- entitySingular %>Clock, Application.<%- entitySingular %>Clock.of({ now: () => Effect.sync(() => new Date().toISOString()) })), + Context.add(Application.<%- entitySingular %>IdGenerator, Application.<%- entitySingular %>IdGenerator.of({ next: () => Schema.decodeUnknownEffect(<%- entitySingular %>Id)(randomUUID()).pipe(Effect.mapError(() => new <%- entitySingular %>IdExhausted())) })), + Context.add(Application.<%- entitySingular %>Events, eventServices.service), + ) +})) diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/tests/__entityId__-runtime.test.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/tests/__entityId__-runtime.test.ts.template new file mode 100644 index 00000000..04f7af48 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-3/__targetRoot__/tests/__entityId__-runtime.test.ts.template @@ -0,0 +1,13 @@ +import { expect, it } from "@effect/vitest" +import * as Application from "<%- applicationImport %>" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import { <%- entitySingular %>TestProbe, testLayer } from "../src/index.js" + +it.effect("publishes ordered Test Layer events after writes", () => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provideMerge(testLayer({ ids: ["<%- entityId %>-1"], now: "2026-01-01T00:00:00.000Z" }))) + const services = yield* Effect.all({ app: Application.<%- entitySingular %>Application, probe: <%- entitySingular %>TestProbe }).pipe(Effect.provide(layer)) + const added = yield* services.app.add("write tests") + yield* services.app.complete(added.id) + expect(yield* services.probe.events()).toEqual([{ _tag: "<%- entitySingular %>Added", <%- entityIdentifier %>: added }, { _tag: "<%- entitySingular %>Completed", <%- entityIdentifier %>: { ...added, completed: true } }]) +})) diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/package.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/package.json.template new file mode 100644 index 00000000..c347ca92 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/package.json.template @@ -0,0 +1,19 @@ +{ + "name": "<%- presentationImport %>", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "pnpm -w exec tsc -p tsconfig.build.json", + "test": "pnpm -w exec vitest run --config vitest.config.mts", + "typecheck": "pnpm -w exec tsc --noEmit -p tsconfig.build.json" + }, + "dependencies": { + "effect": "catalog:", + "<%- infrastructureImport %>": "workspace:*", + "<%- applicationImport %>": "workspace:*", + "<%- domainImport %>": "workspace:*" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/src/index.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/src/index.ts.template new file mode 100644 index 00000000..1027f816 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/src/index.ts.template @@ -0,0 +1,21 @@ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Application from "<%- applicationImport %>" +import type { <%- entitySingular %>Event } from "<%- domainImport %>" +import * as Infrastructure from "<%- infrastructureImport %>" + +export const createTestRuntime = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provideMerge(Infrastructure.testLayer(input))) + const services = yield* Effect.all({ application: Application.<%- entitySingular %>Application, probe: Infrastructure.<%- entitySingular %>TestProbe }).pipe(Effect.provide(layer)) + return Object.freeze({ ...services.application, events: services.probe.events }) +}) + +export const createLiveRuntime = (path: string) => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provide(Infrastructure.liveLayer(path))) + return yield* Application.<%- entitySingular %>Application.pipe(Effect.provide(layer)) +}) + +export const renderEvent = (event: <%- entitySingular %>Event): string => + event._tag === "<%- entitySingular %>Added" ? "added:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text + : event._tag === "<%- entitySingular %>Completed" ? "completed:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text + : "removed:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/tests/__entityId__.test.ts.template b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/tests/__entityId__.test.ts.template new file mode 100644 index 00000000..87127da5 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/layer-4/__targetRoot__/tests/__entityId__.test.ts.template @@ -0,0 +1,9 @@ +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import { createTestRuntime } from "../src/index.js" + +it.effect("runs deterministic <%- entitySingular %> behavior", () => Effect.gen(function* () { + const <%- entityIdentifier %> = yield* createTestRuntime({ ids: ["<%- entityId %>-1"], now: "2026-01-01T00:00:00.000Z" }) + expect(yield* <%- entityIdentifier %>.list()).toEqual([]) + expect((yield* <%- entityIdentifier %>.add("write tests")).id).toBe("<%- entityId %>-1") +})) 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 new file mode 100644 index 00000000..d74ff27e --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/nx.json.template @@ -0,0 +1,21 @@ +{ + "defaultBase": "HEAD", + "plugins": [ + { + "plugin": "@nx/js/typescript", + "exclude": ["tsconfig.build.json"], + "options": { + "typecheck": { + "targetName": "typecheck" + } + } + }, + { + "plugin": "@nx/vitest", + "exclude": ["vitest.config.mts"], + "options": { + "testTargetName": "test" + } + } + ] +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/package.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/package.json.template new file mode 100644 index 00000000..24aa68a4 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/package.json.template @@ -0,0 +1,20 @@ +{ + "name": "<%- workspacePackageName %>", + "packageManager": "pnpm@10.14.0", + "private": true, + "scripts": { + "build": "pnpm exec tsc -p tsconfig.build.json", + "test": "pnpm exec vitest run --config vitest.config.mts", + "typecheck": "pnpm exec tsc --noEmit -p tsconfig.build.json" + }, + "devDependencies": { + "@effect/vitest": "catalog:", + "@types/node": "catalog:", + "@nx/js": "catalog:", + "@nx/vitest": "23.1.0", + "effect": "catalog:", + "nx": "23.1.0", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/pnpm-workspace.yaml.template b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/pnpm-workspace.yaml.template new file mode 100644 index 00000000..61cde7ea --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/pnpm-workspace.yaml.template @@ -0,0 +1,10 @@ +packages: +<%- workspacePackageGlobs %> + +catalog: + "@effect/vitest": 4.0.0-beta.102 + "@nx/js": 23.1.0 + "@types/node": 20.19.25 + effect: 4.0.0-beta.102 + typescript: 6.0.3 + vitest: 4.1.10 diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/tsconfig.build.json.template b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/tsconfig.build.json.template new file mode 100644 index 00000000..2ea2f396 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/tsconfig.build.json.template @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [ + "node" + ] + }, + "include": [ +<%- workspaceSourceIncludes %> + ] +} diff --git a/packages/app-builder/generation/src/templates/assets/blueprint/workspace/vitest.config.mts.template b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/vitest.config.mts.template new file mode 100644 index 00000000..4c1fa7d5 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/blueprint/workspace/vitest.config.mts.template @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ + test: { + environment: "node", + include: [<%- workspaceTestIncludes %>], + watch: false, + }, +}) diff --git a/packages/app-builder/generation/src/templates/assets/generic/event/__targetRoot__/src/event.ts.template b/packages/app-builder/generation/src/templates/assets/generic/event/__targetRoot__/src/event.ts.template new file mode 100644 index 00000000..9bf225b6 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/event/__targetRoot__/src/event.ts.template @@ -0,0 +1,9 @@ +import * as Data from "effect/Data" +import type { <%- entitySingular %> } from "./model.js" + +export type <%- entitySingular %>Event = Data.TaggedEnum<{ + <%- entitySingular %>Added: { readonly <%- entityIdentifier %>: <%- entitySingular %> } + <%- entitySingular %>Completed: { readonly <%- entityIdentifier %>: <%- entitySingular %> } + <%- entitySingular %>Removed: { readonly <%- entityIdentifier %>: <%- entitySingular %> } +}> +export const <%- entitySingular %>Event = Data.taggedEnum<<%- entitySingular %>Event>() diff --git a/packages/app-builder/generation/src/templates/assets/generic/integration-adapter/__targetRoot__/src/adapter.ts.template b/packages/app-builder/generation/src/templates/assets/generic/integration-adapter/__targetRoot__/src/adapter.ts.template new file mode 100644 index 00000000..ffb63ad5 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/integration-adapter/__targetRoot__/src/adapter.ts.template @@ -0,0 +1,91 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises" +import { randomUUID } from "node:crypto" +import { dirname } from "node:path" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as PubSub from "effect/PubSub" +import * as Ref from "effect/Ref" +import * as Stream from "effect/Stream" +import * as Schema from "effect/Schema" +import * as Application from "<%- applicationImport %>" +import { <%- entitySingular %>, <%- entitySingular %>Id, <%- entitySingular %>IdExhausted, <%- entitySingular %>PersistenceError, type <%- entitySingular %> as Model, type <%- entitySingular %>Event as Event } from "<%- domainImport %>" + +export interface <%- entitySingular %>TestProbeApi { readonly events: () => Effect.Effect> } +export class <%- entitySingular %>TestProbe extends Context.Service<<%- entitySingular %>TestProbe, <%- entitySingular %>TestProbeApi>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>TestProbe") {} + +const decodeValues = Schema.decodeUnknownEffect(Schema.Array(<%- entitySingular %>)) +const events = Effect.gen(function* () { + const visible = yield* Ref.make>([]) + const published = yield* PubSub.unbounded() + return { + probe: <%- entitySingular %>TestProbe.of({ events: () => Ref.get(visible) }), + service: Application.<%- entitySingular %>Events.of({ + publish: (event) => Ref.update(visible, (current) => [...current, event]).pipe( + Effect.andThen(PubSub.publish(published, event)), + Effect.asVoid, + ), + }), + stream: Stream.fromPubSub(published), + } +}) + +const repository = (read: () => Effect.Effect, <%- entitySingular %>PersistenceError>, write: (values: ReadonlyArray) => Effect.EffectPersistenceError>) => + Application.<%- entitySingular %>Repository.of({ + find: (id) => read().pipe(Effect.map((values) => values.find((value) => value.id === id))), + list: read, + remove: (id) => Effect.gen(function* () { + const values = yield* read() + const value = values.find((candidate) => candidate.id === id) + if (value === undefined) return yield* Effect.fail(new <%- entitySingular %>PersistenceError({ operation: "write" })) + yield* write(values.filter((candidate) => candidate.id !== id)) + return value + }), + save: (value) => read().pipe( + Effect.flatMap((values) => write([...values.filter((candidate) => candidate.id !== value.id), value])), + ), + }) + +const fixedIds = (ids: ReadonlyArray) => Effect.gen(function* () { + const remaining = yield* Ref.make(ids) + return Application.<%- entitySingular %>IdGenerator.of({ next: () => Effect.gen(function* () { + const [next, ...rest] = yield* Ref.get(remaining) + if (next === undefined) return yield* Effect.fail(new <%- entitySingular %>IdExhausted()) + yield* Ref.set(remaining, rest) + return yield* Schema.decodeUnknownEffect(<%- entitySingular %>Id)(next).pipe(Effect.mapError(() => new <%- entitySingular %>IdExhausted())) + }) }) +}) + +export const testLayer = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Layer.effectContext(Effect.gen(function* () { + const values = yield* Ref.make>([]) + const eventServices = yield* events + return Context.empty().pipe( + Context.add(Application.<%- entitySingular %>Repository, repository(() => Ref.get(values), (next) => Ref.set(values, next))), + Context.add(Application.<%- entitySingular %>Clock, Application.<%- entitySingular %>Clock.of({ now: () => Effect.succeed(input.now) })), + Context.add(Application.<%- entitySingular %>IdGenerator, yield* fixedIds(input.ids)), + Context.add(Application.<%- entitySingular %>Events, eventServices.service), + Context.add(<%- entitySingular %>TestProbe, eventServices.probe), + ) +})) + +export const liveLayer = (path: string) => Layer.effectContext(Effect.gen(function* () { + yield* Effect.tryPromise({ try: () => mkdir(dirname(path), { recursive: true }), catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }) }) + const exists = yield* Effect.promise(() => access(path).then(() => true, () => false)) + if (!exists) yield* Effect.tryPromise({ try: () => writeFile(path, "[]\n"), catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }) }) + const read = () => Effect.tryPromise({ try: () => readFile(path, "utf8"), catch: () => new <%- entitySingular %>PersistenceError({ operation: "read" }) }).pipe( + Effect.flatMap((source) => Effect.try({ try: () => JSON.parse(source), catch: () => new <%- entitySingular %>PersistenceError({ operation: "read" }) })), + Effect.flatMap(decodeValues), + Effect.mapError(() => new <%- entitySingular %>PersistenceError({ operation: "read" })), + ) + const write = (values: ReadonlyArray) => Effect.tryPromise({ + try: () => writeFile(path, JSON.stringify(values, null, 2) + "\n"), + catch: () => new <%- entitySingular %>PersistenceError({ operation: "write" }), + }) + const eventServices = yield* events + return Context.empty().pipe( + Context.add(Application.<%- entitySingular %>Repository, repository(read, write)), + Context.add(Application.<%- entitySingular %>Clock, Application.<%- entitySingular %>Clock.of({ now: () => Effect.sync(() => new Date().toISOString()) })), + Context.add(Application.<%- entitySingular %>IdGenerator, Application.<%- entitySingular %>IdGenerator.of({ next: () => Schema.decodeUnknownEffect(<%- entitySingular %>Id)(randomUUID()).pipe(Effect.mapError(() => new <%- entitySingular %>IdExhausted())) })), + Context.add(Application.<%- entitySingular %>Events, eventServices.service), + ) +})) diff --git a/packages/app-builder/generation/src/templates/assets/generic/interface/__targetRoot__/src/entrypoint.ts.template b/packages/app-builder/generation/src/templates/assets/generic/interface/__targetRoot__/src/entrypoint.ts.template new file mode 100644 index 00000000..21ed912b --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/interface/__targetRoot__/src/entrypoint.ts.template @@ -0,0 +1,21 @@ +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Application from "<%- applicationImport %>" +import * as Infrastructure from "<%- infrastructureImport %>" +import type { <%- entitySingular %>Event } from "<%- domainImport %>" + +export const createTestRuntime = (input: { readonly ids: ReadonlyArray; readonly now: string }) => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provideMerge(Infrastructure.testLayer(input))) + const services = yield* Effect.all({ application: Application.<%- entitySingular %>Application, probe: Infrastructure.<%- entitySingular %>TestProbe }).pipe(Effect.provide(layer)) + return Object.freeze({ ...services.application, events: services.probe.events }) +}) + +export const createLiveRuntime = (path: string) => Effect.gen(function* () { + const layer = Application.layer.pipe(Layer.provide(Infrastructure.liveLayer(path))) + return yield* Application.<%- entitySingular %>Application.pipe(Effect.provide(layer)) +}) + +export const renderEvent = (event: <%- entitySingular %>Event): string => + event._tag === "<%- entitySingular %>Added" ? "added:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text + : event._tag === "<%- entitySingular %>Completed" ? "completed:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text + : "removed:" + event.<%- entityIdentifier %>.id + ":" + event.<%- entityIdentifier %>.text diff --git a/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/src/model.ts.template b/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/src/model.ts.template new file mode 100644 index 00000000..322d0380 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/src/model.ts.template @@ -0,0 +1,21 @@ +import * as Data from "effect/Data" +import * as Schema from "effect/Schema" + +export const <%- entitySingular %>Id = Schema.NonEmptyString.pipe(Schema.brand("<%- brandNamespace %>.<%- entitySingular %>Id")) +export type <%- entitySingular %>Id = typeof <%- entitySingular %>Id.Type + +export const <%- entitySingular %> = Schema.Struct({ + completed: Schema.Boolean, + createdAt: Schema.String, + id: <%- entitySingular %>Id, + text: Schema.NonEmptyString, +}) +export type <%- entitySingular %> = typeof <%- entitySingular %>.Type + +export class <%- entitySingular %>TextInvalid extends Data.TaggedError("<%- entitySingular %>TextInvalid")<{ readonly reason: "empty" }> {} +export class <%- entitySingular %>NotFound extends Data.TaggedError("<%- entitySingular %>NotFound")<{ readonly id: string }> {} +export class <%- entitySingular %>AlreadyCompleted extends Data.TaggedError("<%- entitySingular %>AlreadyCompleted")<{ readonly id: string }> {} +export class <%- entitySingular %>IdExhausted extends Data.TaggedError("<%- entitySingular %>IdExhausted")<{}> {} +export class <%- entitySingular %>PersistenceError extends Data.TaggedError("<%- entitySingular %>PersistenceError")<{ + readonly operation: "read" | "write" +}> {} diff --git a/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/tests/model.test.ts.template b/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/tests/model.test.ts.template new file mode 100644 index 00000000..03dd4dc2 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/model/__targetRoot__/tests/model.test.ts.template @@ -0,0 +1,7 @@ +import { expect, it } from "vitest" +import * as Schema from "effect/Schema" +import { <%- entitySingular %> } from "../src/index.js" + +it("decodes a <%- entitySingular %> schema", () => { + expect(Schema.is(<%- entitySingular %>)({ completed: false, createdAt: "2026-01-01T00:00:00.000Z", id: "<%- entityId %>-1", text: "write tests" })).toBe(true) +}) diff --git a/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/package.json.template b/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/package.json.template new file mode 100644 index 00000000..bad05112 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/package.json.template @@ -0,0 +1,9 @@ +{ + "name": "<%- packageName %>", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": <%- dependenciesJson %> +} diff --git a/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/src/index.ts.template b/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/src/index.ts.template new file mode 100644 index 00000000..c62d67b4 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/package/__targetRoot__/src/index.ts.template @@ -0,0 +1,3 @@ +<% JSON.parse(exportsJson).forEach((entry) => { -%> +export { <%- entry.name %> } from "<%- entry.from %>" +<% }) -%> diff --git a/packages/app-builder/generation/src/templates/assets/generic/port/__targetRoot__/src/port.ts.template b/packages/app-builder/generation/src/templates/assets/generic/port/__targetRoot__/src/port.ts.template new file mode 100644 index 00000000..6c3e1eb2 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/port/__targetRoot__/src/port.ts.template @@ -0,0 +1,15 @@ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import { <%- entitySingular %>Id, type <%- entitySingular %>, type <%- entitySingular %>Event as Event, <%- entitySingular %>IdExhausted, <%- entitySingular %>PersistenceError } from "<%- domainImport %>" + +export interface <%- entitySingular %>RepositoryApi { + readonly find: (id: <%- entitySingular %>Id) => Effect.Effect<<%- entitySingular %> | undefined, <%- entitySingular %>PersistenceError> + readonly list: () => Effect.Effect>, <%- entitySingular %>PersistenceError> + readonly remove: (id: <%- entitySingular %>Id) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>PersistenceError> + readonly save: (<%- entityIdentifier %>: <%- entitySingular %>) => Effect.EffectPersistenceError> +} +export class <%- entitySingular %>Repository extends Context.Service<<%- entitySingular %>Repository, <%- entitySingular %>RepositoryApi>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>Repository") {} + +export class <%- entitySingular %>Clock extends Context.Service<<%- entitySingular %>Clock, { readonly now: () => Effect.Effect }>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>Clock") {} +export class <%- entitySingular %>IdGenerator extends Context.Service<<%- entitySingular %>IdGenerator, { readonly next: () => Effect.Effect<<%- entitySingular %>Id, <%- entitySingular %>IdExhausted> }>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>IdGenerator") {} +export class <%- entitySingular %>Events extends Context.Service<<%- entitySingular %>Events, { readonly publish: (event: Event) => Effect.Effect }>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>Events") {} diff --git a/packages/app-builder/generation/src/templates/assets/generic/use-case/__targetRoot__/src/use-case.ts.template b/packages/app-builder/generation/src/templates/assets/generic/use-case/__targetRoot__/src/use-case.ts.template new file mode 100644 index 00000000..bc072033 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/use-case/__targetRoot__/src/use-case.ts.template @@ -0,0 +1,56 @@ +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as Schema from "effect/Schema" +import { <%- entitySingular %>Event, <%- entitySingular %>Id, type <%- entitySingular %>, <%- entitySingular %>AlreadyCompleted, <%- entitySingular %>IdExhausted, <%- entitySingular %>NotFound, <%- entitySingular %>PersistenceError, <%- entitySingular %>TextInvalid } from "<%- domainImport %>" +import { <%- entitySingular %>Clock, <%- entitySingular %>Events, <%- entitySingular %>IdGenerator, <%- entitySingular %>Repository } from "./port.js" + +type <%- entitySingular %>Failure = <%- entitySingular %>AlreadyCompleted | <%- entitySingular %>IdExhausted | <%- entitySingular %>NotFound | <%- entitySingular %>PersistenceError | <%- entitySingular %>TextInvalid + +export interface <%- entitySingular %>ApplicationApi { + readonly add: (text: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> + readonly complete: (id: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> + readonly list: () => Effect.Effect>, <%- entitySingular %>PersistenceError> + readonly remove: (id: string) => Effect.Effect<<%- entitySingular %>, <%- entitySingular %>Failure> +} +export class <%- entitySingular %>Application extends Context.Service<<%- entitySingular %>Application, <%- entitySingular %>ApplicationApi>()("<%- workspaceScope %>/<%- entityId %>/<%- entitySingular %>Application") {} + +const resolveId = (id: string): Effect.Effect<<%- entitySingular %>Id, <%- entitySingular %>NotFound> => + Schema.decodeUnknownEffect(<%- entitySingular %>Id)(id).pipe(Effect.mapError(() => new <%- entitySingular %>NotFound({ id }))) + +export const layer = Layer.effect(<%- entitySingular %>Application, Effect.gen(function* () { + const repository = yield* <%- entitySingular %>Repository + const clock = yield* <%- entitySingular %>Clock + const ids = yield* <%- entitySingular %>IdGenerator + const events = yield* <%- entitySingular %>Events + const find = Effect.fn("<%- entitySingular %>Application.find")(function* (rawId: string) { + const id = yield* resolveId(rawId) + const value = Option.fromUndefinedOr(yield* repository.find(id)) + if (Option.isNone(value)) return yield* Effect.fail(new <%- entitySingular %>NotFound({ id: rawId })) + return value.value + }) + const add = Effect.fn("<%- entitySingular %>Application.add")(function* (text: string) { + const normalized = text.trim() + if (normalized.length === 0) return yield* Effect.fail(new <%- entitySingular %>TextInvalid({ reason: "empty" })) + const value: <%- entitySingular %> = { completed: false, createdAt: yield* clock.now(), id: yield* ids.next(), text: normalized } + yield* repository.save(value) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Added({ <%- entityIdentifier %>: value })) + return value + }) + const complete = Effect.fn("<%- entitySingular %>Application.complete")(function* (id: string) { + const value = yield* find(id) + if (value.completed) return yield* Effect.fail(new <%- entitySingular %>AlreadyCompleted({ id })) + const completed = { ...value, completed: true } + yield* repository.save(completed) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Completed({ <%- entityIdentifier %>: completed })) + return completed + }) + const remove = Effect.fn("<%- entitySingular %>Application.remove")(function* (id: string) { + const value = yield* find(id) + yield* repository.remove(value.id) + yield* events.publish(<%- entitySingular %>Event.<%- entitySingular %>Removed({ <%- entityIdentifier %>: value })) + return value + }) + return <%- entitySingular %>Application.of({ add, complete, list: repository.list, remove }) +})) diff --git a/packages/app-builder/generation/src/templates/assets/generic/workspace/nx.json.template b/packages/app-builder/generation/src/templates/assets/generic/workspace/nx.json.template new file mode 100644 index 00000000..e053d2e7 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/workspace/nx.json.template @@ -0,0 +1,19 @@ +{ + "defaultBase": "HEAD", + "plugins": [ + { + "plugin": "@nx/js/typescript", + "options": { + "typecheck": { + "targetName": "typecheck" + } + } + }, + { + "plugin": "@nx/vitest", + "options": { + "testTargetName": "test" + } + } + ] +} diff --git a/packages/app-builder/generation/src/templates/assets/generic/workspace/package.json.template b/packages/app-builder/generation/src/templates/assets/generic/workspace/package.json.template new file mode 100644 index 00000000..7df46670 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/workspace/package.json.template @@ -0,0 +1,17 @@ +{ + "name": "<%- workspacePackageName %>", + "packageManager": "pnpm@10.14.0", + "private": true, + "scripts": { + "build": "pnpm exec tsc -p tsconfig.build.json", + "test": "pnpm exec vitest run --config vitest.config.mts", + "typecheck": "pnpm exec tsc --noEmit -p tsconfig.build.json" + }, + "devDependencies": { + "@nx/js": "23.1.0", + "@nx/vitest": "23.1.0", + "nx": "23.1.0", + "typescript": "6.0.3", + "vitest": "4.1.10" + } +} diff --git a/packages/app-builder/generation/src/templates/assets/generic/workspace/pnpm-workspace.yaml.template b/packages/app-builder/generation/src/templates/assets/generic/workspace/pnpm-workspace.yaml.template new file mode 100644 index 00000000..7d26c8c1 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/workspace/pnpm-workspace.yaml.template @@ -0,0 +1,2 @@ +packages: +<%- workspacePackageGlobs %> diff --git a/packages/app-builder/generation/src/templates/assets/generic/workspace/tsconfig.build.json.template b/packages/app-builder/generation/src/templates/assets/generic/workspace/tsconfig.build.json.template new file mode 100644 index 00000000..35cc34f8 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/workspace/tsconfig.build.json.template @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "target": "ES2022", + "types": [ + "node" + ] + }, + "include": [ +<%- workspaceSourceIncludes %> + ] +} diff --git a/packages/app-builder/generation/src/templates/assets/generic/workspace/vitest.config.mts.template b/packages/app-builder/generation/src/templates/assets/generic/workspace/vitest.config.mts.template new file mode 100644 index 00000000..45cef5c1 --- /dev/null +++ b/packages/app-builder/generation/src/templates/assets/generic/workspace/vitest.config.mts.template @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config" + +export default defineConfig({ test: { environment: "node", watch: false } }) diff --git a/packages/app-builder/generation/src/templates/todo/index.ts b/packages/app-builder/generation/src/templates/todo/index.ts deleted file mode 100644 index 08fd65e6..00000000 --- a/packages/app-builder/generation/src/templates/todo/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { applicationTemplate } from "./application.js" -import { cliTemplate } from "./cli.js" -import { domainTemplate } from "./domain.js" -import { infrastructureTemplate } from "./infrastructure.js" - -export interface TodoTemplateFile { - readonly content: string - readonly path: string -} - -const testTemplate = `import { expect, it } from "@effect/vitest"\nimport * as Effect from "effect/Effect"\nimport { createTestRuntime } from "../src/index.js"\n\nit.effect("runs deterministic Todo behavior", () => Effect.gen(function* () {\n const todo = yield* createTestRuntime({ ids: ["todo-1"], now: "2026-01-01T00:00:00.000Z" })\n expect(yield* todo.list()).toEqual([])\n expect((yield* todo.add("write tests")).id).toBe("todo-1")\n}))\n` -const domainTestTemplate = `import { expect, it } from "vitest"\nimport * as Schema from "effect/Schema"\nimport { Todo } from "../src/index.js"\n\nit("decodes a Todo schema", () => {\n expect(Schema.is(Todo)({ completed: false, createdAt: "2026-01-01T00:00:00.000Z", id: "todo-1", text: "write tests" })).toBe(true)\n})\n` -const infrastructureTestTemplate = `import { expect, it } from "@effect/vitest"\nimport * as Effect from "effect/Effect"\nimport * as Layer from "effect/Layer"\nimport * as Application from "../../application/src/index.js"\nimport { TodoTestProbe, testLayer } from "../src/index.js"\n\nit.effect("publishes ordered Test Layer events after writes", () => Effect.gen(function* () {\n const layer = Application.layer.pipe(Layer.provideMerge(testLayer({ ids: ["todo-1"], now: "2026-01-01T00:00:00.000Z" })))\n const services = yield* Effect.all({ app: Application.TodoApplication, probe: TodoTestProbe }).pipe(Effect.provide(layer))\n const added = yield* services.app.add("write tests")\n yield* services.app.complete(added.id)\n expect(yield* services.probe.events()).toEqual([{ _tag: "TodoAdded", todo: added }, { _tag: "TodoCompleted", todo: { ...added, completed: true } }])\n}))\n` - -const files: ReadonlyArray = [ - { content: domainTemplate, path: "packages/todo/domain/src/index.ts" }, - { content: applicationTemplate, path: "packages/todo/application/src/index.ts" }, - { content: infrastructureTemplate, path: "packages/todo/infrastructure/src/index.ts" }, - { content: cliTemplate, path: "apps/todo-cli/src/index.ts" }, - { content: domainTestTemplate, path: "packages/todo/domain/tests/todo.test.ts" }, - { content: infrastructureTestTemplate, path: "packages/todo/infrastructure/tests/todo-runtime.test.ts" }, - { content: testTemplate, path: "apps/todo-cli/tests/todo.test.ts" }, -] - -export const todoTemplateFiles = (): ReadonlyArray => Object.freeze([...files]) - -export const todoTemplateContent = (path: string): string => { - const file = files.find((candidate) => candidate.path === path) - if (file === undefined) throw new Error(`Unknown Todo template: ${path}`) - return file.content -} diff --git a/packages/app-builder/generation/src/todo-preset.ts b/packages/app-builder/generation/src/todo-preset.ts index a63db6c5..4c054d9a 100644 --- a/packages/app-builder/generation/src/todo-preset.ts +++ b/packages/app-builder/generation/src/todo-preset.ts @@ -1,6 +1,11 @@ import * as Data from "effect/Data" import * as Effect from "effect/Effect" -import { allTodoGenerationBlocks, type GenerationBlockFile } from "./generators/index.js" +import { + composeTodoV1Atomic, + DefaultTodoRenderContext, + WorkspaceRootFiles, + type GenerationBlockFile, +} from "./generators/index.js" import type { TodoPlan } from "./planner.js" export const TodoTopologyRoots = [ @@ -10,14 +15,22 @@ export const TodoTopologyRoots = [ "apps/todo-cli", ] as const -export type TodoTopologyRoot = (typeof TodoTopologyRoots)[number] +export type TodoTopologyRoot = string +export type TodoPackageRole = "application" | "domain" | "infrastructure" | "presentation" export type TodoTopologyFile = GenerationBlockFile +/** The canonical workspace surface emitted with the Todo package and application outputs. */ +export const isTodoTopologyPath = (path: string, roots: ReadonlyArray = TodoTopologyRoots): boolean => + WorkspaceRootFiles.some((file) => file === path) || roots.some((root) => path.startsWith(`${root}/`)) + +export { DefaultTodoRenderContext } + export interface TodoTopologyProject { readonly dependencies: ReadonlyArray readonly name: string readonly root: TodoTopologyRoot + readonly role: TodoPackageRole } export interface TodoTopology { @@ -27,7 +40,7 @@ export interface TodoTopology { } export class TodoPresetError extends Data.TaggedError("TodoPresetError")<{ - readonly reason: "missing-capability" + readonly reason: "atomic-composition" | "missing-capability" }> {} const requiredCapabilities = [ @@ -40,62 +53,69 @@ const requiredCapabilities = [ "todo.events", ] as const -const owner = "@effectify/app-builder/workspace/1" - -const packageFile = (name: string, dependencies: Readonly>): string => - `${JSON.stringify({ name, private: true, type: "module", dependencies }, null, 2)}\n` - -const project = (name: string, root: TodoTopologyRoot, dependencies: ReadonlyArray): TodoTopologyProject => ({ - dependencies: Object.freeze([...dependencies]), - name, - root, -}) - -const packageFilesFor = (topologyProject: TodoTopologyProject): ReadonlyArray => { - const dependencies = Object.fromEntries([ - ["effect", "catalog:"], - ...topologyProject.dependencies.map((dependency) => [dependency, "workspace:*"]), - ]) - return [ - { - content: packageFile(topologyProject.name, dependencies), - owner, - path: `${topologyProject.root}/package.json`, - }, - ] -} - const hasRequiredCapabilities = (plan: TodoPlan): boolean => requiredCapabilities.every((capability) => plan.orderedCapabilities.includes(capability)) -/** Builds the owned, deterministic four-root Todo topology without an Nx Tree dependency. */ +/** Builds the public Todo topology directly from the canonical atomic catalog without an Nx Tree dependency. */ export const createTodoTopology = (plan: TodoPlan): Effect.Effect => { if (!hasRequiredCapabilities(plan)) { return Effect.fail(new TodoPresetError({ reason: "missing-capability" })) } - const projects = Object.freeze([ - project("@effectify/todo-domain", "packages/todo/domain", []), - project("@effectify/todo-application", "packages/todo/application", ["@effectify/todo-domain"]), - project("@effectify/todo-infrastructure", "packages/todo/infrastructure", [ - "@effectify/todo-application", - "@effectify/todo-domain", - ]), - project("@effectify/todo-cli", "apps/todo-cli", [ - "@effectify/todo-infrastructure", - "@effectify/todo-application", - "@effectify/todo-domain", - ]), - ]) + const naming = plan.intent.naming ?? { + workspace: DefaultTodoRenderContext.workspace.name, + npmScope: DefaultTodoRenderContext.workspace.npmScope, + domain: DefaultTodoRenderContext.domain, + entity: DefaultTodoRenderContext.entity, + entrypoint: DefaultTodoRenderContext.entrypoint, + } + const roles = ["domain", "application", "infrastructure", "presentation"] as const + const packages = roles.map((id) => ({ + id, + name: `${naming.npmScope}/${id === "presentation" ? naming.entrypoint.id : `${naming.domain.id}-${id}`}`, + root: id === "presentation" ? `apps/${naming.entrypoint.id}` : `packages/${naming.domain.id}/${id}`, + })) + const [domain, , , presentation] = packages + const projects = Object.freeze( + packages.map((target, index) => + Object.freeze({ + ...target, + role: target.id, + dependencies: Object.freeze( + packages + .slice(0, index) + .reverse() + .map(({ name }) => name), + ), + }), + ), + ) + const context = { + version: "effectify.render-context/1" as const, + workspace: { name: naming.workspace, npmScope: naming.npmScope }, + domain: { ...naming.domain, importName: domain.name }, + entity: naming.entity, + entrypoint: { ...naming.entrypoint, importName: presentation.name }, + packages, + } - return Effect.succeed( - Object.freeze({ - files: Object.freeze([ - ...projects.flatMap(packageFilesFor), - ...allTodoGenerationBlocks().flatMap((block) => block.files), - ]), - projects, - roots: Object.freeze([...TodoTopologyRoots]), - }), + return composeTodoV1Atomic(context).pipe( + Effect.map(({ contributions }) => + Object.freeze({ + files: Object.freeze( + contributions.map((file) => + Object.freeze({ + content: new TextDecoder().decode(file.bytes), + owner: file.owner, + path: file.path, + ...(file.template === undefined ? {} : { template: file.template }), + }), + ), + ), + projects, + roots: Object.freeze(packages.map(({ root }) => root)), + }), + ), + Effect.mapError(() => new TodoPresetError({ reason: "atomic-composition" })), ) } diff --git a/packages/app-builder/generation/src/todo-v1.ts b/packages/app-builder/generation/src/todo-v1.ts new file mode 100644 index 00000000..7bf1341a --- /dev/null +++ b/packages/app-builder/generation/src/todo-v1.ts @@ -0,0 +1,73 @@ +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" + +export type TodoV1OwnerIdentity = Readonly<{ owner: string; path: string }> +export class TodoV1OwnerMigrationError extends Data.TaggedError("TodoV1OwnerMigrationError")<{ + readonly path: string + readonly reason: + | "already-invalid-owner" + | "ambiguous-owner" + | "duplicate-path" + | "mismatched-path" + | "missing-output" + | "unknown-owner" +}> {} + +const roots = ["nx.json", "package.json", "pnpm-workspace.yaml", "tsconfig.build.json", "vitest.config.mts"] +const packages = [ + ["packages/todo/domain", "domain", "model"], + ["packages/todo/application", "application", "port"], + ["packages/todo/infrastructure", "infrastructure", "integration-adapter"], + ["apps/todo-cli", "presentation", "presentation"], +] as const +const leaves: Readonly> = { + "packages/todo/application/src/use-case.ts": ["use-case", "todo-use-case-src-use-case-ts"], + "packages/todo/domain/src/events.ts": ["event", "todo-event-src-events-ts"], + "packages/todo/domain/tests/todo.test.ts": ["model", "todo-model-tests-todo-test-ts"], + "packages/todo/infrastructure/tests/todo-runtime.test.ts": [ + "integration-adapter", + "todo-integration-adapter-tests-todo-runtime-test-ts", + ], + "apps/todo-cli/tests/todo.test.ts": ["presentation", "todo-presentation-tests-todo-test-ts"], +} +const legacy = (owner: string) => `@effectify/app-builder/${owner}/1` +const expectedPaths = new Set([ + ...roots, + ...packages.flatMap(([root]) => [`${root}/package.json`, `${root}/src/index.ts`]), + ...Object.keys(leaves), +]) +const expected = (path: string): readonly [legacy: string, current: string] | undefined => { + if (roots.includes(path)) return [legacy("workspace"), `workspace-surface-${path.replace(/[^a-z0-9]+/g, "-")}`] + const surface = packages.find(([root]) => path === `${root}/package.json` || path === `${root}/src/index.ts`) + if (surface !== undefined) + return [ + legacy(path.endsWith("package.json") ? "workspace" : surface[2]), + `package-surface-${surface[1]}-${path.endsWith("package.json") ? "manifest" : "barrel"}`, + ] + const leaf = leaves[path] + return leaf === undefined ? undefined : [legacy(leaf[0]), `todo-${leaf[0]}-${path.replace(/[^a-z0-9]+/g, "-")}`] +} +const currentOwners = new Set([...expectedPaths].flatMap((path) => expected(path)?.[1] ?? [])) +const legacyOwners = new Set([...expectedPaths].flatMap((path) => expected(path)?.[0] ?? [])) +const failure = (path: string, reason: TodoV1OwnerMigrationError["reason"]) => + Effect.fail(new TodoV1OwnerMigrationError({ path, reason })) + +export const migrateTodoV1Owners = ( + identities: ReadonlyArray, +): Effect.Effect, TodoV1OwnerMigrationError> => + Effect.gen(function* () { + const paths = new Set() + const migrated: Array = [] + for (const identity of identities) { + if (paths.has(identity.path)) return yield* failure(identity.path, "duplicate-path") + paths.add(identity.path) + const owner = expected(identity.path) + if (owner === undefined) return yield* failure(identity.path, "mismatched-path") + if (currentOwners.has(identity.owner)) return yield* failure(identity.path, "already-invalid-owner") + if (identity.owner === "package-surface") return yield* failure(identity.path, "ambiguous-owner") + if (!legacyOwners.has(identity.owner)) return yield* failure(identity.path, "unknown-owner") + if (identity.owner !== owner[0]) return yield* failure(identity.path, "mismatched-path") + migrated.push(Object.freeze({ ...identity, owner: owner[1] })) + } + return paths.size === expectedPaths.size ? Object.freeze(migrated) : yield* failure("todo-v1", "missing-output") + }) diff --git a/packages/app-builder/generation/tests/evolution.test.ts b/packages/app-builder/generation/tests/evolution.test.ts index 269dce8a..3d433423 100644 --- a/packages/app-builder/generation/tests/evolution.test.ts +++ b/packages/app-builder/generation/tests/evolution.test.ts @@ -1,11 +1,16 @@ -import { createTreeWithEmptyWorkspace } from "@nx/devkit/testing" +import { createTree } from "@nx/devkit/testing" import { expect, it } from "@effect/vitest" import * as Effect from "effect/Effect" const blockIds = ["model", "use-case", "port", "integration-adapter", "event", "presentation"] as const type BlockId = (typeof blockIds)[number] -type GeneratedFile = { readonly content: string; readonly owner: string; readonly path: string } +type GeneratedFile = { + readonly content: string + readonly owner: string + readonly path: string + readonly template?: unknown +} type Topology = { readonly files: ReadonlyArray readonly projects: ReadonlyArray @@ -13,7 +18,7 @@ type Topology = { } type ApplyPlanModule = { readonly applyTodoTopology: ( - tree: ReturnType, + tree: ReturnType, topology: Topology, ) => Effect.Effect< { readonly generatedRoots: ReadonlyArray; readonly writtenPaths: ReadonlyArray }, @@ -24,7 +29,23 @@ type EvolutionModule = { readonly planTodoEvolution: (blocks: ReadonlyArray) => Effect.Effect } type PlannerModule = { readonly planTodo: (input: unknown) => Effect.Effect } -type TodoPresetModule = { readonly createTodoTopology: (plan: unknown) => Effect.Effect } +type TodoPresetModule = { + readonly DefaultTodoRenderContext: unknown + readonly createTodoTopology: (plan: unknown) => Effect.Effect +} +type TodoGenerationModule = { + readonly composeTodoAtomic: (context: unknown) => Effect.Effect< + { + readonly contributions: ReadonlyArray<{ + readonly bytes: Uint8Array + readonly owner: string + readonly path: string + readonly template?: unknown + }> + }, + unknown + > +} const dependencies: Readonly>> = { model: [], @@ -36,7 +57,7 @@ const dependencies: Readonly>> = { } const ownerFor = (id: BlockId): string => `@effectify/app-builder/${id}/1` -const valuesAt = (tree: ReturnType, paths: ReadonlyArray) => +const valuesAt = (tree: ReturnType, paths: ReadonlyArray) => Object.fromEntries(paths.map((path) => [path, tree.read(path, "utf8")])) const modules = () => @@ -47,14 +68,40 @@ const modules = () => Evolution: Effect.promise(() => import(new URL("../src/evolution.js", import.meta.url).href)), Planner: Effect.promise(() => import(new URL("../src/planner.js", import.meta.url).href)), TodoPreset: Effect.promise(() => import(new URL("../src/todo-preset.js", import.meta.url).href)), + TodoGeneration: Effect.promise( + () => import(new URL("../src/generators/index.js", import.meta.url).href), + ), }) +it.effect("the public Todo topology is exactly the direct atomic catalog output", () => + Effect.gen(function* () { + const { Planner, TodoGeneration, TodoPreset } = yield* modules() + const topology = yield* TodoPreset.createTodoTopology( + yield* Planner.planTodo({ + capabilities: ["todo.events"], + preset: "todo", + version: "effectify.creation-intent/1", + }), + ) + const direct = yield* TodoGeneration.composeTodoAtomic(TodoPreset.DefaultTodoRenderContext) + + expect(topology.files).toEqual( + direct.contributions.map((file) => ({ + content: new TextDecoder().decode(file.bytes), + owner: file.owner, + path: file.path, + template: file.template, + })), + ) + }), +) + it.effect("R03 and S05 compose dependency-closed additions that write only their owned leaves", () => Effect.gen(function* () { const { ApplyPlan, Evolution } = yield* modules() for (const id of blockIds) { - const tree = createTreeWithEmptyWorkspace() + const tree = createTree() const baseline = yield* Evolution.planTodoEvolution(dependencies[id]) yield* ApplyPlan.applyTodoTopology(tree, baseline) tree.write("notes/user-authored.ts", "export const preserved = true\n") @@ -79,15 +126,9 @@ it.effect("R03 and S05 compose dependency-closed additions that write only their it.effect("R04 and S06 evolve a four-root Todo baseline and replay the same addition with zero diff", () => Effect.gen(function* () { - const { ApplyPlan, Evolution, Planner, TodoPreset } = yield* modules() - const tree = createTreeWithEmptyWorkspace() - const fullTopology = yield* TodoPreset.createTodoTopology( - yield* Planner.planTodo({ - capabilities: ["todo.events"], - preset: "todo", - version: "effectify.creation-intent/1", - }), - ) + const { ApplyPlan, Evolution } = yield* modules() + const tree = createTree() + const fullTopology = yield* Evolution.planTodoEvolution(blockIds) const baseline = { ...fullTopology, files: fullTopology.files.filter((file) => file.owner !== ownerFor("event")) } yield* ApplyPlan.applyTodoTopology(tree, baseline) const event = yield* Evolution.planTodoEvolution(["event"]) @@ -99,10 +140,10 @@ it.effect("R04 and S06 evolve a four-root Todo baseline and replay the same addi ) const replay = yield* ApplyPlan.applyTodoTopology(tree, event) - expect(tree.exists("packages/todo/domain/package.json")).toBe(true) - expect(tree.exists("packages/todo/application/package.json")).toBe(true) - expect(tree.exists("packages/todo/infrastructure/package.json")).toBe(true) - expect(tree.exists("apps/todo-cli/package.json")).toBe(true) + expect(tree.exists("packages/todo/domain/src/index.ts")).toBe(true) + expect(tree.exists("packages/todo/application/src/index.ts")).toBe(true) + expect(tree.exists("packages/todo/infrastructure/src/index.ts")).toBe(true) + expect(tree.exists("apps/todo-cli/src/index.ts")).toBe(true) expect(first.generatedRoots).toEqual(["packages/todo/domain"]) expect(first.writtenPaths).toEqual(eventFiles.map((file) => file.path)) expect(replay.writtenPaths).toEqual([]) @@ -118,7 +159,7 @@ it.effect("R04 and S06 evolve a four-root Todo baseline and replay the same addi it.effect("S07 rejects changed owned output before the Tree adapter mutates another leaf", () => Effect.gen(function* () { const { ApplyPlan, Evolution } = yield* modules() - const tree = createTreeWithEmptyWorkspace() + const tree = createTree() const topology = yield* Evolution.planTodoEvolution(["presentation"]) yield* ApplyPlan.applyTodoTopology(tree, topology) const changed = topology.files.find((file) => file.owner === ownerFor("presentation")) @@ -144,7 +185,7 @@ it.effect("S07 rejects changed owned output before the Tree adapter mutates anot it.effect("S08 rejects an unowned target without touching unrelated user-authored code", () => Effect.gen(function* () { const { ApplyPlan, Evolution } = yield* modules() - const tree = createTreeWithEmptyWorkspace() + const tree = createTree() const topology = yield* Evolution.planTodoEvolution(["model"]) const [target] = topology.files if (target === undefined) throw new Error("model must own generated output") diff --git a/packages/app-builder/generation/tests/fixtures/todo-v1.ts b/packages/app-builder/generation/tests/fixtures/todo-v1.ts new file mode 100644 index 00000000..e1219a6b --- /dev/null +++ b/packages/app-builder/generation/tests/fixtures/todo-v1.ts @@ -0,0 +1,24 @@ +type Entry = ReadonlyArray +/** Frozen Todo v1 bytes and ownership transitions; never derive this from a renderer. */ +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|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 +packages/todo/application/src/use-case.ts|fce13645ef2926c343137b96fd0b2a0cd9b2e73046d8608ff3daa7204755c7df|@effectify/app-builder/use-case/1|todo-use-case-packages-todo-application-src-use-case-ts +packages/todo/domain/package.json|9ed2f277b6b9bf848e1c077d7a600aea00c391a05d558cc6ba0ca8134023baca|@effectify/app-builder/workspace/1|package-surface-domain-manifest +packages/todo/domain/src/events.ts|3826f139fcac911e8b84298d319400e2265730c62460b40501b3c1668be950f8|@effectify/app-builder/event/1|todo-event-packages-todo-domain-src-events-ts +packages/todo/domain/src/index.ts|e003ca3479b14b5d3f94fdd0c8db7ffe20269aad29628e0d09f7f367cd2cc7f8|@effectify/app-builder/model/1|package-surface-domain-barrel +packages/todo/domain/tests/todo.test.ts|99e4f463a497275f7bf6dacbe0402dde1b054dbe8e0568256dccd6a6a5448514|@effectify/app-builder/model/1|todo-model-packages-todo-domain-tests-todo-test-ts +packages/todo/infrastructure/package.json|5a28a70e873ee887a1819bafa2f11ae4d47cd44aaaac410d621493346d186889|@effectify/app-builder/workspace/1|package-surface-infrastructure-manifest +packages/todo/infrastructure/src/index.ts|73bfa06f4aac3cfee6fad9425490d86dbfab70ec79a69d2559a41c93bd391e35|@effectify/app-builder/integration-adapter/1|package-surface-infrastructure-barrel +packages/todo/infrastructure/tests/todo-runtime.test.ts|8a305172097e942a07398ee5bfeec90e2a84a07ea5b97050138c8549dcf9949a|@effectify/app-builder/integration-adapter/1|todo-integration-adapter-packages-todo-infrastructure-tests-todo-runtime-test-ts +pnpm-workspace.yaml|38153e8982dc83ad4f576c62dd12e80fd222668b7ebd29c9c89092a41bf10544|@effectify/app-builder/workspace/1|workspace-surface-pnpm-workspace-yaml +tsconfig.build.json|a12ff29d34e79ed58b9d9540572968f6f32721482f776500b2f2495810926138|@effectify/app-builder/workspace/1|workspace-surface-tsconfig-build-json +vitest.config.mts|3d6177cfd34114394a841bb24fdf661f2879b0b75cfe2d793ae414d2191081de|@effectify/app-builder/workspace/1|workspace-surface-vitest-config-mts` + .split("\n") + .map((line) => Object.freeze(line.split("|"))), +) diff --git a/packages/app-builder/generation/tests/kernel.test.ts b/packages/app-builder/generation/tests/kernel.test.ts new file mode 100644 index 00000000..be34b524 --- /dev/null +++ b/packages/app-builder/generation/tests/kernel.test.ts @@ -0,0 +1,136 @@ +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Schema from "effect/Schema" + +type CatalogModule = typeof import("../src/catalog.js") +type KernelModule = typeof import("../src/kernel.js") +type Generator = import("../src/kernel.js").AtomicGenerator<{ readonly content: string }> + +const context = { + version: "effectify.render-context/1", + workspace: { name: "todo", npmScope: "@acme" }, + domain: { id: "todo", name: "Todo", importName: "@acme/todo" }, + entity: { id: "todo", singular: "Todo", plural: "Todos" }, + entrypoint: { id: "todo", name: "Todo", importName: "@acme/todo" }, + packages: [{ id: "domain", name: "@acme/todo", root: "pkg/todo" }], +} as const +const duplicatePackage = { ...context.packages[0], id: "domain", name: "@acme/app", root: "app" } +const contribution = (Kernel: KernelModule, path: string, owner: string, content: string) => ({ + bytes: new TextEncoder().encode(content), + mode: "100644", + owner: Kernel.identifier(owner), + package: Kernel.identifier("domain"), + path: Kernel.safeRelativePath(path), + sourceDigest: Kernel.sourceDigest("sha256:test"), + surface: Kernel.identifier("source"), +}) +const generator = ( + Kernel: KernelModule, + id: string, + onRender?: () => void, + requires: ReadonlyArray = [], + owner = id, + paths: ReadonlyArray = [`${id}.ts`], +): Generator => + Kernel.atomicGenerator({ + InputSchema: Schema.Struct({ content: Schema.String }), + id: Kernel.identifier(id), + provides: Kernel.capabilities(id), + requires: Kernel.capabilities(...requires), + version: "1", + render: (input) => { + onRender?.() + return Effect.succeed(paths.map((path) => contribution(Kernel, path, owner, input.content))) + }, + }) +const modules = () => + Effect.all({ + Catalog: Effect.promise(() => import(new URL("../src/catalog.js", import.meta.url).href)), + Kernel: Effect.promise(() => import(new URL("../src/kernel.js", import.meta.url).href)), + }) +const compose = ( + Catalog: CatalogModule, + catalog: import("../src/kernel.js").FiniteCatalog, + context: unknown, + selected: ReadonlyArray, +) => Catalog.composeCatalog({ catalog, context, input: { content: "todo\n" }, selected }) +it.effect("rejects invalid RenderContext shapes before any generator runs", () => + Effect.gen(function* () { + const { Catalog, Kernel } = yield* modules() + let renders = 0 + const catalog = Kernel.defineCatalog([generator(Kernel, "model", () => renders++)]) + const selected = [Kernel.identifier("model")] + const invalid = [ + [{ workspace: { ...context.workspace, npmScope: "acme" } }, "schema"], + [{ packages: [{ ...context.packages[0], root: "../escape" }] }, "schema"], + [{ packages: [...context.packages, duplicatePackage] }, "derived-identity"], + [{ entrypoint: { ...context.entrypoint, importName: "@acme/missing" } }, "derived-identity"], + [{ unexpected: true }, "schema"], + ] as const + for (const [patch, reason] of invalid) { + const failure = yield* compose(Catalog, catalog, { ...context, ...patch }, selected).pipe(Effect.flip) + expect(failure).toMatchObject({ _tag: "SchemaContextFailure", boundary: "context", reason }) + } + expect(renders).toBe(0) + }), +) +it.effect("closes a finite catalog in deterministic dependency order and snapshots graph authority", () => + Effect.gen(function* () { + const { Catalog, Kernel } = yield* modules() + const app = { provides: [...Kernel.capabilities("application")], requires: [...Kernel.capabilities("model")] } + const modelGraph = { provides: [...Kernel.capabilities("model")], requires: [...Kernel.capabilities()] } + const application = Kernel.atomicGenerator({ + ...generator(Kernel, "application", undefined, ["model"], "application", ["a.ts"]), + ...app, + }) + const model = Kernel.atomicGenerator({ + ...generator(Kernel, "model", undefined, [], "model", ["z.ts"]), + ...modelGraph, + }) + const catalog = Kernel.defineCatalog([application, model]) + for (const values of [modelGraph.provides, app.requires]) { + Reflect.apply(Array.prototype.splice, values, [0, 1, Kernel.capability("edge")]) + } + const first = yield* compose(Catalog, catalog, context, [Kernel.identifier("application")]) + expect(first.generatorIds).toEqual(["model", "application"]) + expect(first.contributions.map((file) => file.path)).toEqual(["a.ts", "z.ts"]) + expect(catalog.flatMap(({ provides, requires }) => [...provides, ...requires])).toEqual( + Kernel.capabilities("application", "model", "model"), + ) + }), +) +it.effect("fails closed for missing, cyclic, duplicate catalog, and output path identities", () => + Effect.gen(function* () { + const { Catalog, Kernel } = yield* modules() + const g = (id: string, requires: ReadonlyArray = [], owner = id, paths?: ReadonlyArray) => + generator(Kernel, id, undefined, requires, owner, paths) + const one = (generator: Generator) => Kernel.defineCatalog([generator]) + const two = (left: Generator, right: Generator) => Kernel.defineCatalog([left, right]) + const graph = (capability: string, reason: string) => ["CapabilityGraphFailure", capability, reason] as const + const conflict = (identity: string, reason: string) => ["ContributionConflict", identity, reason] as const + const missing = one(g("application", ["model"])) + const cyclic = two(g("application", ["model"]), g("model", ["application"])) + const duplicateId = two(g("same"), g("same")) + const repeatedOwner = one(g("same-owner", [], "owner", ["one.ts", "two.ts"])) + const duplicatePath = two(g("first", [], "first", ["same.ts"]), g("second", [], "second", ["same.ts"])) + const scenarios = [ + [missing, ["application"], graph("model", "missing-capability")], + [cyclic, ["application"], graph("application", "cyclic-capability")], + [duplicateId, ["same"], graph("same", "duplicate-generator-id")], + [duplicatePath, ["first", "second"], conflict("same.ts", "duplicate-path")], + ] as const + for (const [catalog, selected, [tag, identity, reason]] of scenarios) { + const failure = yield* compose(Catalog, catalog, context, selected.map(Kernel.identifier)).pipe(Effect.flip) + expect(failure).toMatchObject( + tag === "CapabilityGraphFailure" + ? { _tag: tag, capability: identity, reason } + : { _tag: tag, identity, reason }, + ) + } + const result = yield* compose(Catalog, repeatedOwner, context, [Kernel.identifier("same-owner")]) + expect(result.contributions.map(({ owner, path }) => ({ owner, path }))).toEqual([ + { owner: "owner", path: "one.ts" }, + { owner: "owner", path: "two.ts" }, + ]) + }), +) diff --git a/packages/app-builder/generation/tests/planner.test.ts b/packages/app-builder/generation/tests/planner.test.ts index 4e3cfda9..ceed31d6 100644 --- a/packages/app-builder/generation/tests/planner.test.ts +++ b/packages/app-builder/generation/tests/planner.test.ts @@ -17,6 +17,13 @@ const intent = { preset: "todo", capabilities: ["todo.events", "todo.community-labels", "todo.cli-presentation"], } +const 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 modules = () => Effect.all({ @@ -35,15 +42,25 @@ it.effect("S01 returns the canonical Todo envelope and visible plan fixture", () }), ) -it.effect("R01 rejects arbitrary module, template, callback, and command selectors", () => +it.effect("accepts bounded naming authority and rejects unsafe or excess input", () => Effect.gen(function* () { - const { Intent } = yield* modules() + const { Intent, Planner } = yield* modules() + const plan = yield* Planner.planTodo({ ...intent, naming }) + expect(plan.intent.naming).toEqual(naming) + expect(Object.isFrozen(plan.intent.naming?.entity)).toBe(true) for (const selector of ["module", "template", "callback", "command"]) { const failure = yield* Intent.decodeCreationIntent({ ...intent, [selector]: "node:child_process" }).pipe( Effect.flip, ) expect(failure).toMatchObject({ _tag: "InvalidCreationIntent", reason: "schema" }) } + for (const invalid of [ + { ...naming, domain: { ...naming.domain, id: "../operations" } }, + { ...naming, entrypoint: { ...naming.entrypoint, command: "rm -rf /" } }, + ]) { + const failure = yield* Intent.decodeCreationIntent({ ...intent, naming: invalid }).pipe(Effect.flip) + expect(failure).toMatchObject({ _tag: "InvalidCreationIntent", reason: "schema" }) + } }), ) @@ -85,9 +102,10 @@ it.effect("R02 rejects unavailable and incompatible community metadata without l it.effect("S03 and S04 close dependencies in a deterministic order for equivalent selectors", () => Effect.gen(function* () { const { Planner } = yield* modules() - const first = yield* Planner.planTodo(intent) - const second = yield* Planner.planTodo({ ...intent, capabilities: [...intent.capabilities].reverse() }) + const first = yield* Planner.planTodo({ ...intent, naming }) + const second = yield* Planner.planTodo({ ...intent, naming, capabilities: [...intent.capabilities].reverse() }) expect(first).toEqual(second) + expect(first.intent.naming).toEqual(naming) expect(first.orderedCapabilities).toEqual(JSON.parse(fixture).orderedCapabilities) }), ) 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]) + }), +) diff --git a/packages/app-builder/generation/tests/replay.test.ts b/packages/app-builder/generation/tests/replay.test.ts new file mode 100644 index 00000000..5405b160 --- /dev/null +++ b/packages/app-builder/generation/tests/replay.test.ts @@ -0,0 +1,381 @@ +import { createTree } from "@nx/devkit/testing" +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" + +interface TodoPlan { + readonly catalogVersion: string + readonly intent: unknown + readonly orderedCapabilities: ReadonlyArray +} + +interface TopologyFile { + readonly content: string + readonly owner: string + readonly path: string +} + +interface Topology { + readonly files: ReadonlyArray +} + +interface PlannerModule { + readonly planTodo: (input: unknown) => Effect.Effect +} + +interface TodoPresetModule { + readonly createTodoTopology: (plan: TodoPlan) => Effect.Effect +} + +interface ApplyPlanModule { + readonly applyTodoTopology: ( + tree: ReturnType, + topology: Topology, + ) => Effect.Effect<{ readonly writtenPaths: ReadonlyArray }, unknown> +} + +interface CjsonModule { + readonly canonicalDigest: (input: unknown) => Effect.Effect +} + +interface ReplayOutputIdentity { + readonly mode: string + readonly owner: string + readonly path: string + readonly sourceDigest: string +} + +interface ReplayProvenance { + readonly canonicalJson: string + readonly evidence: Readonly> + readonly outputIdentities: ReadonlyArray + readonly provenanceDigest: string + readonly version: string +} + +interface ProvenanceModule { + readonly captureReplayProvenance: (candidate: unknown) => Effect.Effect + readonly semanticDependencyDigest: (dependencies: unknown) => Effect.Effect +} + +interface ReplayModule { + readonly validateReplay: ( + provenance: ReplayProvenance, + candidate: unknown, + workspace: { readonly readOutput: (path: string) => string | undefined }, + ) => Effect.Effect<{ readonly diffPaths: ReadonlyArray; readonly zeroDiff: boolean }, unknown> +} + +const intent = { + capabilities: [ + "todo.workspace", + "todo.model", + "todo.port", + "todo.use-case", + "todo.file-adapter", + "todo.cli-presentation", + "todo.events", + ], + preset: "todo", + version: "effectify.creation-intent/1", +} + +const dependencies = [ + { + importer: "workspace", + integrity: "sha512-effect", + name: "effect", + peers: { typescript: "6.0.3" }, + registry: "https://registry-one.example.invalid", + storePath: "/one/store/effect", + version: "4.0.0", + }, +] + +const pins = { + effect: "4.0.0", + frozenInstall: true, + nx: "23.1.0", + packageManager: "pnpm@10.14.0", + plugins: [{ name: "@effectify/app-builder-generation", version: "0.0.0" }], +} + +const modules = () => + Effect.all({ + ApplyPlan: Effect.promise( + () => import(new URL("../../nx-plugin/src/apply-plan.js", import.meta.url).href), + ), + Cjson: Effect.promise(() => import(new URL("../src/cjson.js", import.meta.url).href)), + Planner: Effect.promise(() => import(new URL("../src/planner.js", import.meta.url).href)), + Provenance: Effect.promise(() => import(new URL("../src/provenance.js", import.meta.url).href)), + Replay: Effect.promise(() => import(new URL("../src/replay.js", import.meta.url).href)), + TodoPreset: Effect.promise(() => import(new URL("../src/todo-preset.js", import.meta.url).href)), + }) + +const candidateFor = (plan: TodoPlan, topology: Topology, dependencyInput: unknown = dependencies) => ({ + blocks: ["workspace", "model", "port", "event", "use-case", "integration-adapter", "presentation"], + catalog: { capabilities: plan.orderedCapabilities, version: plan.catalogVersion }, + dependencies: dependencyInput, + intent: plan.intent, + outputs: topology.files.map((file) => ({ + content: file.content, + mode: "100644", + owner: file.owner, + path: file.path, + })), + pins, + plan, +}) + +const valuesAt = (tree: ReturnType, paths: ReadonlyArray) => + Object.fromEntries(paths.map((path) => [path, tree.read(path, "utf8")])) + +it.effect("S21 canonicalizes source digests and semantic dependencies without lock transport metadata", () => + Effect.gen(function* () { + const { Cjson, Provenance } = yield* modules() + + const left = yield* Cjson.canonicalDigest({ nested: { a: 1, b: 2 }, value: "same" }) + const right = yield* Cjson.canonicalDigest({ value: "same", nested: { b: 2, a: 1 } }) + const firstDependencies = yield* Provenance.semanticDependencyDigest(dependencies) + const secondDependencies = yield* Provenance.semanticDependencyDigest([ + { + ...dependencies[0], + registry: "https://registry-two.example.invalid", + storePath: "/two/store/effect", + }, + ]) + + expect(left).toBe(right) + expect(firstDependencies).toBe(secondDependencies) + }), +) + +it.effect("R13 and S22-S23 record frozen provenance then replay a fresh Tree with zero writes and zero diff", () => + Effect.gen(function* () { + const { ApplyPlan, Planner, Provenance, Replay, TodoPreset } = yield* modules() + const plan = yield* Planner.planTodo(intent) + const topology = yield* TodoPreset.createTodoTopology(plan) + const candidate = candidateFor(plan, topology) + const provenance = yield* Provenance.captureReplayProvenance(candidate) + const unfrozen = yield* Provenance.captureReplayProvenance({ + ...candidate, + pins: { ...pins, frozenInstall: false }, + }).pipe(Effect.flip) + const tree = createTree() + yield* ApplyPlan.applyTodoTopology(tree, topology) + tree.write("notes/user-authored.ts", "export const preserved = true\n") + const paths = topology.files.map((file) => file.path) + const before = valuesAt(tree, paths) + + const replay = yield* Replay.validateReplay(provenance, candidate, { + readOutput: (path) => tree.read(path, "utf8") ?? undefined, + }) + const reapplied = yield* ApplyPlan.applyTodoTopology(tree, topology) + + expect(provenance.version).toBe("effectify.app-builder-replay-provenance/1") + expect(unfrozen).toMatchObject({ _tag: "ReplayProvenanceError", field: "pins", reason: "unfrozen-install" }) + expect(replay).toEqual({ diffPaths: [], zeroDiff: true }) + expect(reapplied.writtenPaths).toEqual([]) + expect(valuesAt(tree, paths)).toEqual(before) + expect(tree.read("notes/user-authored.ts", "utf8")).toBe("export const preserved = true\n") + }), +) + +it.effect("R12 and S24 reject dependency or output mismatch before writes and preserve unrelated user bytes", () => + Effect.gen(function* () { + const { ApplyPlan, Planner, Provenance, Replay, TodoPreset } = yield* modules() + const plan = yield* Planner.planTodo(intent) + const topology = yield* TodoPreset.createTodoTopology(plan) + const candidate = candidateFor(plan, topology) + const provenance = yield* Provenance.captureReplayProvenance(candidate) + const tree = createTree() + yield* ApplyPlan.applyTodoTopology(tree, topology) + tree.write("notes/user-authored.ts", "export const keep = 'unchanged'\n") + const paths = topology.files.map((file) => file.path) + const before = valuesAt(tree, paths) + const [firstOutput] = candidate.outputs + if (firstOutput === undefined) throw new Error("replay fixture requires an owned output") + + const dependencyFailure = yield* Replay.validateReplay( + provenance, + candidateFor(plan, topology, [{ ...dependencies[0], version: "4.1.0" }]), + { readOutput: (path) => tree.read(path, "utf8") ?? undefined }, + ).pipe(Effect.flip) + const candidateFailure = yield* Replay.validateReplay( + provenance, + { ...candidate, outputs: [{ ...firstOutput, content: "candidate-drift\n" }, ...candidate.outputs.slice(1)] }, + { readOutput: (path) => tree.read(path, "utf8") ?? undefined }, + ).pipe(Effect.flip) + + expect(valuesAt(tree, paths)).toEqual(before) + tree.write(firstOutput.path, "workspace-drift\n") + const workspaceFailure = yield* Replay.validateReplay(provenance, candidate, { + readOutput: (path) => tree.read(path, "utf8") ?? undefined, + }).pipe(Effect.flip) + + expect(dependencyFailure).toMatchObject({ + _tag: "ReplayEvidenceError", + identity: "dependencies", + phase: "candidate", + }) + expect(candidateFailure).toMatchObject({ _tag: "ReplayEvidenceError", identity: "outputs", phase: "candidate" }) + expect(workspaceFailure).toMatchObject({ _tag: "ReplayEvidenceError", identity: "outputs", phase: "workspace" }) + expect(valuesAt(tree, paths)).toEqual({ ...before, [firstOutput.path]: "workspace-drift\n" }) + expect(tree.read("notes/user-authored.ts", "utf8")).toBe("export const keep = 'unchanged'\n") + }), +) + +it.effect("requires non-empty output identities before recording replay provenance", () => + Effect.gen(function* () { + const { Planner, Provenance, TodoPreset } = yield* modules() + const plan = yield* Planner.planTodo(intent) + const topology = yield* TodoPreset.createTodoTopology(plan) + const attempt = yield* Provenance.captureReplayProvenance({ ...candidateFor(plan, topology), outputs: [] }).pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "failure" as const, error }), + onSuccess: () => ({ _tag: "success" as const }), + }), + ) + + expect(attempt).toMatchObject({ + _tag: "failure", + error: { _tag: "ReplayProvenanceError", field: "outputs", reason: "invalid-candidate" }, + }) + }), +) + +it.effect("rejects re-digested truncated or reordered output identities before workspace reads", () => + Effect.gen(function* () { + const { Cjson, Planner, Provenance, Replay, TodoPreset } = yield* modules() + const plan = yield* Planner.planTodo(intent) + const topology = yield* TodoPreset.createTodoTopology(plan) + const candidate = candidateFor(plan, topology) + const provenance = yield* Provenance.captureReplayProvenance(candidate) + const [firstOutputIdentity] = provenance.outputIdentities + if (firstOutputIdentity === undefined) throw new Error("replay fixture requires multiple output identities") + + const truncatedIdentities = provenance.outputIdentities.slice(1) + const truncatedEvidence = { + ...provenance.evidence, + outputs: yield* Cjson.canonicalDigest(truncatedIdentities), + } + const truncated = { + ...provenance, + evidence: truncatedEvidence, + outputIdentities: truncatedIdentities, + provenanceDigest: yield* Cjson.canonicalDigest({ + canonicalJson: provenance.canonicalJson, + evidence: truncatedEvidence, + outputIdentities: truncatedIdentities, + version: provenance.version, + }), + } + const reorderedIdentities = [...provenance.outputIdentities].reverse() + const reordered = { + ...provenance, + outputIdentities: reorderedIdentities, + provenanceDigest: yield* Cjson.canonicalDigest({ + canonicalJson: provenance.canonicalJson, + evidence: provenance.evidence, + outputIdentities: reorderedIdentities, + version: provenance.version, + }), + } + + for (const [recorded, expected] of [ + [truncated, { identity: "outputs", phase: "candidate" }], + [reordered, { identity: "outputs", phase: "recorded", reason: "mismatch" }], + ] as const) { + let reads = 0 + const attempt = yield* Replay.validateReplay(recorded, candidate, { + readOutput: (path) => { + reads += 1 + return candidate.outputs.find((output) => output.path === path)?.content + }, + }).pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "failure" as const, error }), + onSuccess: () => ({ _tag: "success" as const }), + }), + ) + + expect(attempt).toMatchObject({ _tag: "failure", error: { _tag: "ReplayEvidenceError", ...expected } }) + expect(reads).toBe(0) + } + }), +) + +it.effect("rejects coherently re-digested reordered recorded outputs against the candidate before reads", () => + Effect.gen(function* () { + const { Cjson, Planner, Provenance, Replay, TodoPreset } = yield* modules() + const plan = yield* Planner.planTodo(intent) + const topology = yield* TodoPreset.createTodoTopology(plan) + const candidate = candidateFor(plan, topology) + const provenance = yield* Provenance.captureReplayProvenance(candidate) + const outputIdentities = [...provenance.outputIdentities].reverse() + const evidence = { ...provenance.evidence, outputs: yield* Cjson.canonicalDigest(outputIdentities) } + const recorded = { + ...provenance, + evidence, + outputIdentities, + provenanceDigest: yield* Cjson.canonicalDigest({ + canonicalJson: provenance.canonicalJson, + evidence, + outputIdentities, + version: provenance.version, + }), + } + let reads = 0 + + const failure = yield* Replay.validateReplay(recorded, candidate, { + readOutput: (path) => { + reads += 1 + return candidate.outputs.find((output) => output.path === path)?.content + }, + }).pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "ReplayEvidenceError", identity: "outputs", phase: "candidate" }) + expect(reads).toBe(0) + }), +) + +it.effect("rejects candidate output identity drift before reads", () => + Effect.gen(function* () { + const { Planner, Provenance, Replay, TodoPreset } = yield* modules() + const plan = yield* Planner.planTodo(intent) + const topology = yield* TodoPreset.createTodoTopology(plan) + const candidate = candidateFor(plan, topology) + const provenance = yield* Provenance.captureReplayProvenance(candidate) + const [firstOutput] = candidate.outputs + if (firstOutput === undefined) throw new Error("replay fixture requires an owned output") + const originalIdentity = provenance.outputIdentities.find((identity) => identity.path === firstOutput.path) + if (originalIdentity === undefined) throw new Error("replay fixture requires matching output identity") + + const drifts = [ + ["path", { ...firstOutput, path: "apps/todo-drift.ts" }], + ["mode", { ...firstOutput, mode: "100755" }], + ["owner", { ...firstOutput, owner: "drift-owner" }], + ["sourceDigest", { ...firstOutput, content: `${firstOutput.content}drift\n` }], + ] as const + + for (const [field, replacement] of drifts) { + const driftedCandidate = { + ...candidate, + outputs: [replacement, ...candidate.outputs.slice(1)], + } + const driftedProvenance = yield* Provenance.captureReplayProvenance(driftedCandidate) + const driftedIdentity = driftedProvenance.outputIdentities.find((identity) => identity.path === replacement.path) + if (driftedIdentity === undefined) throw new Error("replay fixture requires drifted output identity") + let reads = 0 + const failure = yield* Replay.validateReplay(provenance, driftedCandidate, { + readOutput: () => { + reads += 1 + return undefined + }, + }).pipe(Effect.flip) + + expect(driftedIdentity[field]).not.toBe(originalIdentity[field]) + expect(failure).toMatchObject({ _tag: "ReplayEvidenceError", identity: "outputs", phase: "candidate" }) + expect(reads).toBe(0) + } + }), +) diff --git a/packages/app-builder/generation/tests/surface-request.ts b/packages/app-builder/generation/tests/surface-request.ts new file mode 100644 index 00000000..96151066 --- /dev/null +++ b/packages/app-builder/generation/tests/surface-request.ts @@ -0,0 +1,26 @@ +type GenerationModule = typeof import("@effectify/app-builder-generation") + +export const surfaceRequest = (Generation: GenerationModule, scope: string, workspace: string) => ({ + catalog: Generation.TodoGeneration.SurfaceCatalog, + context: { + version: "effectify.render-context/1" as const, + workspace: { name: workspace, npmScope: scope }, + domain: { id: "task", name: "Task", importName: `${scope}/task-core` }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "task-app", name: "TaskApp", importName: `${scope}/task-app` }, + packages: [ + { id: "domain", name: `${scope}/task-core`, root: "packages/task/core" }, + { id: "application", name: `${scope}/task-app`, root: "apps/task-app" }, + ], + }, + input: { + packages: [ + { dependencies: [], exports: [{ from: "./domain.js", name: "TaskDomain" }], packageId: "domain" }, + { dependencies: ["domain"], exports: [{ from: "./task.js", name: "Task" }], packageId: "application" }, + ], + }, + selected: [ + Generation.GenerationKernel.identifier("workspace-surface"), + Generation.GenerationKernel.identifier("package-surface"), + ], +}) diff --git a/packages/app-builder/generation/tests/surfaces.test.ts b/packages/app-builder/generation/tests/surfaces.test.ts new file mode 100644 index 00000000..61636047 --- /dev/null +++ b/packages/app-builder/generation/tests/surfaces.test.ts @@ -0,0 +1,103 @@ +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" + +type CatalogModule = typeof import("../src/catalog.js") +type KernelModule = typeof import("../src/kernel.js") +type SurfaceModule = typeof import("../src/generators/surfaces.js") + +const modules = () => + Effect.all({ + Catalog: Effect.promise(() => import(new URL("../src/catalog.js", import.meta.url).href)), + Kernel: Effect.promise(() => import(new URL("../src/kernel.js", import.meta.url).href)), + Surfaces: Effect.promise( + () => import(new URL("../src/generators/surfaces.js", import.meta.url).href), + ), + }) + +const context = ( + scope = "@acme", + workspace = "task-workspace", + roots = { + application: "apps/task-app", + domain: "packages/task/core", + infrastructure: "packages/task/infrastructure", + }, +) => ({ + version: "effectify.render-context/1" as const, + workspace: { name: workspace, npmScope: scope }, + domain: { id: "task", name: "Task", importName: `${scope}/task-core` }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "task-app", name: "TaskApp", importName: `${scope}/task-app` }, + packages: [ + { id: "domain", name: `${scope}/task-core`, root: roots.domain }, + { id: "application", name: `${scope}/task-app`, root: roots.application }, + { id: "infrastructure", name: `${scope}/task-infrastructure`, root: roots.infrastructure }, + ], +}) + +const request = (Kernel: KernelModule, Surfaces: SurfaceModule, renderContext = context()) => ({ + catalog: Surfaces.SurfaceCatalog, + context: renderContext, + input: { + packages: [ + { dependencies: [], exports: [{ from: "./domain.js", name: "TaskDomain" }], packageId: "domain" }, + { dependencies: ["domain"], exports: [{ from: "./task.js", name: "Task" }], packageId: "application" }, + { + dependencies: ["application", "domain"], + exports: [{ from: "./repository.js", name: "TaskRepository" }], + packageId: "infrastructure", + }, + ], + }, + selected: [Kernel.identifier("workspace-surface"), Kernel.identifier("package-surface")], +}) + +it.effect("the closed surface catalog owns fixed roots and every validated package", () => + Effect.gen(function* () { + const { Catalog, Kernel, Surfaces } = yield* modules() + const result = yield* Catalog.composeCatalog(request(Kernel, Surfaces)) + const files = new Map( + result.contributions.map((file) => [file.path, new TextDecoder().decode(file.bytes)]), + ) + const workspace = result.contributions.filter((file) => file.package === "workspace") + + expect(result.generatorIds).toEqual(["workspace-surface", "package-surface"]) + expect(workspace.map((file) => file.path)).toEqual([...Surfaces.WorkspaceRootFiles]) + expect([...files.keys()]).toEqual([ + "apps/task-app/package.json", + "apps/task-app/src/index.ts", + "nx.json", + "package.json", + "packages/task/core/package.json", + "packages/task/core/src/index.ts", + "packages/task/infrastructure/package.json", + "packages/task/infrastructure/src/index.ts", + "pnpm-workspace.yaml", + "tsconfig.build.json", + "vitest.config.mts", + ]) + expect(files.get("package.json")).toContain('"name": "@acme/task-workspace"') + expect(files.get("apps/task-app/package.json")).toContain('"@acme/task-core": "workspace:*"') + expect(files.get("apps/task-app/src/index.ts")).toBe('export { Task } from "./task.js"\n') + expect(files.get("packages/task/infrastructure/package.json")).toContain('"@acme/task-app": "workspace:*"') + expect(files.get("packages/task/infrastructure/src/index.ts")).toBe( + 'export { TaskRepository } from "./repository.js"\n', + ) + + const parameterized = yield* Catalog.composeCatalog( + request( + Kernel, + Surfaces, + context("@globex", "console", { + application: "apps/console", + domain: "packages/console/core", + infrastructure: "packages/console/infrastructure", + }), + ), + ) + expect(parameterized.contributions.map((file) => file.path)).toContain("apps/console/package.json") + expect( + new TextDecoder().decode(parameterized.contributions.find((file) => file.path === "package.json")?.bytes), + ).toContain('"name": "@globex/console"') + }), +) diff --git a/packages/app-builder/generation/tests/todo-atomic.test.ts b/packages/app-builder/generation/tests/todo-atomic.test.ts new file mode 100644 index 00000000..d0991676 --- /dev/null +++ b/packages/app-builder/generation/tests/todo-atomic.test.ts @@ -0,0 +1,132 @@ +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import { execFile } from "node:child_process" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { dirname, join } from "node:path/posix" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" + +type AtomicTodoModule = typeof import("../src/generators/index.js") +const context = () => ({ + version: "effectify.render-context/1" as const, + workspace: { name: "task-workspace", npmScope: "@acme" }, + domain: { id: "task", name: "Task", importName: "@acme/task-domain" }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "task-cli", name: "TaskCli", importName: "@acme/task-cli" }, + packages: [ + { id: "domain", name: "@acme/task-domain", root: "modules/task-core" }, + { id: "application", name: "@acme/task-application", root: "modules/task-service" }, + { id: "infrastructure", name: "@acme/task-infrastructure", root: "adapters/task-files" }, + { id: "presentation", name: "@acme/task-cli", root: "tools/task-cli" }, + ], +}) + +const generators = () => + Effect.promise(() => import(new URL("../src/generators/index.js", import.meta.url).href)) + +const implementationPaths = (plan: { readonly contributions: ReadonlyArray<{ readonly path: string }> }) => + plan.contributions + .map((file) => file.path) + .filter((path) => path.includes("/src/") && !path.endsWith("/src/index.ts")) + +const [run, tsc] = [promisify(execFile), fileURLToPath(new URL("../../../../node_modules/.bin/tsc", import.meta.url))] +const compilationError = (cause: unknown) => + typeof cause === "object" && cause !== null && "stdout" in cause ? String(cause.stdout) : String(cause) +const compile = (files: Readonly>) => + Effect.tryPromise({ + catch: compilationError, + try: async () => { + const root = await mkdtemp(join(dirname(fileURLToPath(import.meta.url)), ".todo-atomic-")) + try { + await Promise.all( + Object.entries(files).map(([path, content]) => { + const target = join(root, path) + return mkdir(dirname(target), { recursive: true }).then(() => writeFile(target, content)) + }), + ) + await writeFile( + join(root, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { types: ["node"] }, + extends: fileURLToPath(new URL("../../../../tsconfig.base.json", import.meta.url)), + include: ["**/src/**/*.ts"], + }), + ) + await run(tsc, ["--noEmit", "-p", "tsconfig.json"], { cwd: root }) + } finally { + await rm(root, { force: true, recursive: true }) + } + }, + }) + +it.effect("each Todo capability closes only its real implementation prerequisites", () => + Effect.gen(function* () { + const Todo = yield* generators() + const output = { + event: "modules/task-core/src/event.ts", + "integration-adapter": "adapters/task-files/src/adapter.ts", + model: "modules/task-core/src/model.ts", + port: "modules/task-service/src/port.ts", + presentation: "tools/task-cli/src/presentation.ts", + "use-case": "modules/task-service/src/use-case.ts", + } as const + const expected = { + model: ["model"], + event: ["event", "model"], + port: ["event", "model", "port"], + "use-case": ["event", "model", "port", "use-case"], + "integration-adapter": ["integration-adapter", "event", "model", "port", "use-case"], + presentation: ["integration-adapter", "event", "model", "port", "use-case", "presentation"], + } as const + for (const selected of Object.keys(expected) as Array) { + expect(implementationPaths(yield* Todo.composeTodoAtomic(context(), [selected]))).toEqual( + expected[selected].map((id) => output[id]), + ) + } + const port = yield* Todo.composeTodoAtomic(context(), ["port"]) + expect( + new TextDecoder().decode( + port.contributions.find((file) => file.path === "modules/task-core/src/index.ts")?.bytes, + ), + ).toContain('export { TaskEvent } from "./event.js"') + const missing = yield* Todo.composeTodoAtomic( + { ...context(), packages: context().packages.filter((target) => target.id !== "application") }, + ["model"], + ).pipe(Effect.flip) + const invalid = yield* Todo.composeTodoAtomic(context(), ["unknown"]).pipe(Effect.flip) + expect(missing).toMatchObject({ _tag: "RenderFailure", generatorId: "todo-surface-input", reason: "unsafe-path" }) + expect(invalid).toMatchObject({ + _tag: "CapabilityGraphFailure", + capability: "unknown", + reason: "missing-capability", + }) + }), +) + +it.effect( + "semantically compiles reordered bytes and rejects missing runtime imports", + () => + Effect.gen(function* () { + const Todo = yield* generators() + const plan = yield* Todo.composeTodoAtomic({ ...context(), packages: [...context().packages].reverse() }) + const files = Object.fromEntries( + plan.contributions.map((file) => [file.path, new TextDecoder().decode(file.bytes)]), + ) + yield* compile(files) + expect(Object.values(files).join("\n")).not.toContain("packages/todo/") + expect(yield* Todo.composeTodoAtomic(context())).toEqual(plan) + expect(plan.generatorIds.slice(0, 2)).toEqual(["workspace-surface", "package-surface"]) + expect(new Set(plan.contributions.map((file) => file.path)).size).toBe(plan.contributions.length) + expect(new Set(plan.contributions.map((file) => file.owner)).size).toBe(plan.contributions.length) + const imports = [ + ["modules/task-core/src/model.ts", 'import * as Data from "effect/Data"\n', "Data"], + ["modules/task-service/src/port.ts", 'import * as Context from "effect/Context"\n', "Context"], + ["tools/task-cli/src/presentation.ts", 'import * as Effect from "effect/Effect"\n', "Effect"], + ] as const + for (const [path, source, symbol] of imports) { + const error = yield* compile({ ...files, [path]: files[path].replace(source, "") }).pipe(Effect.flip) + expect(error).toMatch(new RegExp(`Cannot find (name|namespace) '${symbol}'`)) + } + }), + 15_000, +) diff --git a/packages/app-builder/generation/tests/todo-behavior.test.ts b/packages/app-builder/generation/tests/todo-behavior.test.ts index b80dcd0d..4883a378 100644 --- a/packages/app-builder/generation/tests/todo-behavior.test.ts +++ b/packages/app-builder/generation/tests/todo-behavior.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "@effect/vitest" -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises" import { dirname, join } from "node:path" import { pathToFileURL } from "node:url" import * as Effect from "effect/Effect" @@ -35,10 +35,6 @@ interface TodoCliModule { type TodoPresetModule = typeof import("../src/todo-preset.js") -interface TodoTemplateModule { - readonly todoTemplateFiles: () => ReadonlyArray<{ readonly content: string; readonly path: string }> -} - const intent = { version: "effectify.creation-intent/1", preset: "todo", @@ -51,29 +47,33 @@ const modules = () => () => import(new URL("../src/planner.js", import.meta.url).href), ), TodoPreset: Effect.promise(() => import(new URL("../src/todo-preset.js", import.meta.url).href)), - TodoTemplates: Effect.promise( - () => import(new URL("../src/templates/todo/index.js", import.meta.url).href), - ), }) +const linkGeneratedPackages = async (workspace: string): Promise => { + for (const [name, root] of [ + ["todo-domain", "packages/todo/domain"], + ["todo-application", "packages/todo/application"], + ["todo-infrastructure", "packages/todo/infrastructure"], + ["todo-cli", "apps/todo-cli"], + ] as const) { + const target = join(workspace, "node_modules", "@effectify", name) + await mkdir(dirname(target), { recursive: true }) + await symlink(join(workspace, root), target, "dir") + } +} + const withGeneratedTodo = ( use: (cli: TodoCliModule, workspace: string) => Effect.Effect, ): Effect.Effect => Effect.gen(function* () { - const { Planner, TodoPreset, TodoTemplates } = yield* modules() + const { Planner, TodoPreset } = yield* modules() const topology = yield* TodoPreset.createTodoTopology(yield* Planner.planTodo(intent)) const workspace = yield* Effect.tryPromise(() => mkdtemp(join(process.cwd(), "tests/.generated-todo-"))) for (const file of topology.files) { yield* Effect.tryPromise(() => mkdir(dirname(join(workspace, file.path)), { recursive: true })) yield* Effect.tryPromise(() => writeFile(join(workspace, file.path), file.content)) } - const generatedTests = topology.files.filter((file) => file.path.includes("/tests/")) - expect(generatedTests.map((file) => file.path)).toEqual( - TodoTemplates.todoTemplateFiles() - .map((file) => file.path) - .filter((path) => path.includes("/tests/")), - ) - expect(generatedTests.map((file) => file.path)).toContain("packages/todo/infrastructure/tests/todo-runtime.test.ts") + yield* Effect.tryPromise(() => linkGeneratedPackages(workspace)) const cli = yield* Effect.promise( () => import(pathToFileURL(join(workspace, "apps/todo-cli/src/index.ts")).href), ) diff --git a/packages/app-builder/generation/tests/todo-v1.test.ts b/packages/app-builder/generation/tests/todo-v1.test.ts new file mode 100644 index 00000000..bb1671f2 --- /dev/null +++ b/packages/app-builder/generation/tests/todo-v1.test.ts @@ -0,0 +1,67 @@ +import { createHash } from "node:crypto" +import { expect, it } from "@effect/vitest" +import * as Effect from "effect/Effect" +import * as Planner from "../src/planner.js" +import * as TodoGeneration from "../src/generators/index.js" +import * as TodoPreset from "../src/todo-preset.js" +import { TodoV1Fixture } from "./fixtures/todo-v1.js" +const intent = { capabilities: ["todo.events"], preset: "todo", version: "effectify.creation-intent/1" } +const expectedOutputs = TodoV1Fixture.map(([path, digest]) => [path, digest]) +const expectedOwners = TodoV1Fixture.map(([path, _digest, _legacy, owner]) => [path, owner]) +const digests = (files: ReadonlyArray<{ readonly content: string; readonly path: string }>) => + files + .map((file) => [file.path, createHash("sha256").update(file.content).digest("hex")]) + .sort(([left], [right]) => left.localeCompare(right)) +const customIntent = { + ...intent, + naming: { + workspace: "operations-workspace", + npmScope: "@acme", + domain: { id: "operations", name: "Operations" }, + entity: { id: "task", singular: "Task", plural: "Tasks" }, + entrypoint: { id: "admin-console", name: "AdminConsole" }, + }, +} +it.effect("freezes the exact eighteen Todo v1 bytes for public and direct atomic defaults", () => + Effect.gen(function* () { + const plan = yield* Planner.planTodo(intent) + const [publicTopology, direct] = yield* Effect.all([ + TodoPreset.createTodoTopology(plan), + TodoGeneration.composeTodoAtomic(TodoPreset.DefaultTodoRenderContext), + ]) + expect([Object.isFrozen(TodoV1Fixture), TodoV1Fixture.length]).toEqual([true, 18]) + expect(digests(publicTopology.files)).toEqual(expectedOutputs) + expect( + direct.contributions.map((file) => [file.path, createHash("sha256").update(file.bytes).digest("hex")]), + ).toEqual(expectedOutputs) + expect(direct.contributions.map(({ owner, path }) => [path, owner])).toEqual(expectedOwners) + }), +) +it.effect("renders independent names through the public intent, planner, and topology route", () => + Effect.gen(function* () { + const topology = yield* TodoPreset.createTodoTopology(yield* Planner.planTodo(customIntent)) + expect(topology.roots).toEqual([ + "packages/operations/domain", + "packages/operations/application", + "packages/operations/infrastructure", + "apps/admin-console", + ]) + expect(topology.projects.map(({ name }) => name)).toEqual([ + "@acme/operations-domain", + "@acme/operations-application", + "@acme/operations-infrastructure", + "@acme/admin-console", + ]) + expect(topology.files.map(({ path }) => path)).toEqual( + expect.arrayContaining([ + "packages/operations/domain/tests/task.test.ts", + "packages/operations/infrastructure/tests/task-runtime.test.ts", + "apps/admin-console/tests/task.test.ts", + ]), + ) + const source = topology.files.map(({ content }) => content).join("\n") + expect(source).toContain("TaskApplication") + expect(source).toContain("@acme/operations-domain") + expect(source).not.toMatch(/Todo|Effectify|@effectify/) + }), +) diff --git a/packages/app-builder/generation/tsconfig.lib.json b/packages/app-builder/generation/tsconfig.lib.json index 7496b5a3..993b2290 100644 --- a/packages/app-builder/generation/tsconfig.lib.json +++ b/packages/app-builder/generation/tsconfig.lib.json @@ -1,5 +1,11 @@ { "extends": "./tsconfig.json", - "compilerOptions": { "declaration": true, "rootDir": "./src", "lib": ["ES2022"], "target": "ES2022", "types": [] }, + "compilerOptions": { + "declaration": true, + "rootDir": "./src", + "lib": ["ES2022"], + "target": "ES2022", + "types": ["node"] + }, "include": ["src/**/*.ts"] } diff --git a/packages/app-builder/generation/vitest.config.mts b/packages/app-builder/generation/vitest.config.mts index c02ce594..3f876cc6 100644 --- a/packages/app-builder/generation/vitest.config.mts +++ b/packages/app-builder/generation/vitest.config.mts @@ -6,8 +6,14 @@ export default defineConfig({ cacheDir: "../../../node_modules/.vite/packages/app-builder/generation", resolve: { alias: { + "@effectify/app-builder-contracts": fileURLToPath(new URL("../contracts/src/index.ts", import.meta.url)), "@effectify/app-builder-generation": fileURLToPath(new URL("./src/index.ts", import.meta.url)), }, }, - test: { name: "@effectify/app-builder-generation", environment: "node", include: ["tests/**/*.test.ts"], watch: false }, + test: { + name: "@effectify/app-builder-generation", + environment: "node", + include: ["tests/**/*.test.ts"], + watch: false, + }, }) diff --git a/packages/app-builder/nx-plugin/src/apply-plan.ts b/packages/app-builder/nx-plugin/src/apply-plan.ts index 096a649e..770a3718 100644 --- a/packages/app-builder/nx-plugin/src/apply-plan.ts +++ b/packages/app-builder/nx-plugin/src/apply-plan.ts @@ -1,5 +1,6 @@ -import type { Tree } from "@nx/devkit" -import { TodoPreset, type TodoPlan } from "@effectify/app-builder-generation" +import { generateFiles, type Tree } from "@nx/devkit" +import { Templates, TodoPreset, type TodoPlan } from "@effectify/app-builder-generation" +import { Buffer } from "node:buffer" import * as Data from "effect/Data" import * as Effect from "effect/Effect" @@ -14,24 +15,24 @@ export class TodoTopologyApplyError extends Data.TaggedError("TodoTopologyApplyE readonly reason: "dependency-direction" | "ownership-conflict" }> {} -const outwardProjects = new Set(["@effectify/todo-infrastructure", "@effectify/todo-cli"]) - -const isAllowedPath = (path: string): boolean => - TodoPreset.TodoTopologyRoots.some((root) => path.startsWith(`${root}/`)) - -const validateOwnership = (tree: Tree, topology: TodoPreset.TodoTopology): TodoTopologyApplyError | undefined => { - const filesByPath = new Map() +interface StagedOutput { + readonly bytes: Buffer + readonly path: string +} - for (const file of topology.files) { - const existing = filesByPath.get(file.path) - if (!isAllowedPath(file.path) || existing !== undefined) { - return new TodoTopologyApplyError({ path: file.path, reason: "ownership-conflict" }) - } - filesByPath.set(file.path, file) +const isAllowedPath = (path: string, roots: ReadonlyArray): boolean => + path.length > 0 && + !path.includes("\\") && + path + .split("/") + .every((segment) => segment !== "." && segment !== ".." && /^[A-Za-z0-9.][A-Za-z0-9._-]*$/.test(segment)) && + TodoPreset.isTodoTopologyPath(path, roots) - const current = tree.read(file.path, "utf8") - if (current !== null && current !== file.content) { - return new TodoTopologyApplyError({ path: file.path, reason: "ownership-conflict" }) +const validateOwnership = (tree: Tree, outputs: ReadonlyArray): TodoTopologyApplyError | undefined => { + for (const output of outputs) { + const current = tree.read(output.path) + if (current !== null && !current.equals(output.bytes)) { + return new TodoTopologyApplyError({ path: output.path, reason: "ownership-conflict" }) } } @@ -39,9 +40,14 @@ const validateOwnership = (tree: Tree, topology: TodoPreset.TodoTopology): TodoT } const validateDependencies = (topology: TodoPreset.TodoTopology): TodoTopologyApplyError | undefined => { + const outwardNames = new Set( + topology.projects + .filter(({ role }) => role === "infrastructure" || role === "presentation") + .map(({ name }) => name), + ) for (const project of topology.projects) { - const isInwardOnly = project.name === "@effectify/todo-domain" || project.name === "@effectify/todo-application" - if (isInwardOnly && project.dependencies.some((dependency) => outwardProjects.has(dependency))) { + const isInwardOnly = project.role === "domain" || project.role === "application" + if (isInwardOnly && project.dependencies.some((dependency: string) => outwardNames.has(dependency))) { return new TodoTopologyApplyError({ project: project.name, reason: "dependency-direction" }) } } @@ -49,24 +55,84 @@ const validateDependencies = (topology: TodoPreset.TodoTopology): TodoTopologyAp return undefined } +const stageTemplates = ( + tree: Tree, + topology: TodoPreset.TodoTopology, +): ReadonlyArray | TodoTopologyApplyError => { + const paths = new Set() + for (const file of topology.files) { + if (!isAllowedPath(file.path, topology.roots) || file.owner.length === 0 || paths.has(file.path)) { + return new TodoTopologyApplyError({ path: file.path, reason: "ownership-conflict" }) + } + paths.add(file.path) + } + const staged = new Map() + const staging = new Proxy(tree, { + get: (target, property, receiver) => + property === "exists" + ? (path: string) => staged.has(path) + : property === "write" + ? (path: string, content: Buffer | string) => { + if (paths.has(path)) staged.set(path, Buffer.from(content)) + } + : Reflect.get(target, property, receiver), + }) + try { + for (const file of topology.files) if (file.template === undefined) staged.set(file.path, Buffer.from(file.content)) + for (const group of Templates.templateGroups(topology.files)) { + generateFiles(staging, Templates.templateDirectory(group), "", group.substitutions) + } + } catch { + return new TodoTopologyApplyError({ reason: "ownership-conflict" }) + } + if (staged.size !== paths.size) { + return new TodoTopologyApplyError({ reason: "ownership-conflict" }) + } + const outputs: Array = [] + for (const path of paths) { + const bytes = staged.get(path) + if (bytes === undefined) { + return new TodoTopologyApplyError({ path, reason: "ownership-conflict" }) + } + outputs.push(Object.freeze({ bytes, path })) + } + return Object.freeze(outputs) +} + /** Applies a fully validated topology in one Tree-only mutation boundary. */ export const applyTodoTopology = ( tree: Tree, topology: TodoPreset.TodoTopology, ): Effect.Effect => Effect.gen(function* () { - const failure = validateDependencies(topology) ?? validateOwnership(tree, topology) + const dependencyFailure = validateDependencies(topology) + if (dependencyFailure !== undefined) return yield* Effect.fail(dependencyFailure) + const stagedResult = stageTemplates(tree, topology) + if (stagedResult instanceof TodoTopologyApplyError) return yield* Effect.fail(stagedResult) + const staged = stagedResult + const failure = validateOwnership(tree, staged) if (failure !== undefined) { return yield* Effect.fail(failure) } - const writtenPaths: Array = [] - for (const file of topology.files) { - if (tree.read(file.path, "utf8") === null) { - tree.write(file.path, file.content) - writtenPaths.push(file.path) - } - } + const writtenPaths = yield* Effect.try({ + try: () => { + const written: Array = [] + try { + for (const output of staged) { + if (tree.read(output.path, "utf8") === null) { + tree.write(output.path, output.bytes) + written.push(output.path) + } + } + return written + } catch (error) { + for (const path of written.reverse()) tree.delete(path) + throw error + } + }, + catch: () => new TodoTopologyApplyError({ reason: "ownership-conflict" }), + }) return Object.freeze({ generatedRoots: Object.freeze([...topology.roots]), diff --git a/packages/app-builder/nx-plugin/src/index.ts b/packages/app-builder/nx-plugin/src/index.ts index ddb860c4..61d58162 100644 --- a/packages/app-builder/nx-plugin/src/index.ts +++ b/packages/app-builder/nx-plugin/src/index.ts @@ -1,3 +1,17 @@ export * as ApplyPlan from "./apply-plan.js" +import { + composeCatalog, + type AtomicGenerator, + type FiniteCatalog, + type GeneratorId, +} from "@effectify/app-builder-generation" +export const composeGeneration = < + const Generators extends readonly [AtomicGenerator, ...AtomicGenerator[]], +>(options: { + readonly catalog: FiniteCatalog + readonly context: unknown + readonly input: Generators[number] extends AtomicGenerator ? Input : never + readonly selected: ReadonlyArray +}) => composeCatalog(options) export type { AppliedTodoTopology } from "./apply-plan.js" diff --git a/packages/app-builder/nx-plugin/tests/apply-plan.test.ts b/packages/app-builder/nx-plugin/tests/apply-plan.test.ts index 5cd07b59..567abd86 100644 --- a/packages/app-builder/nx-plugin/tests/apply-plan.test.ts +++ b/packages/app-builder/nx-plugin/tests/apply-plan.test.ts @@ -1,8 +1,22 @@ -import { createTreeWithEmptyWorkspace } from "@nx/devkit/testing" +import { createTree } from "@nx/devkit/testing" import { expect, it } from "@effect/vitest" import * as Effect from "effect/Effect" +import { vi } from "vitest" +import { surfaceRequest } from "../../generation/tests/surface-request.js" + +vi.mock("@nx/devkit", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + generateFiles: (...args: Parameters) => { + actual.generateFiles(...args) + }, + } +}) type ApplyPlanModule = typeof import("../src/apply-plan.js") +type PublicModule = typeof import("../src/index.js") +type GenerationModule = typeof import("@effectify/app-builder-generation") type PlannerModule = typeof import("../../generation/src/planner.js") type TodoPresetModule = typeof import("../../generation/src/todo-preset.js") @@ -12,13 +26,22 @@ const roots = [ "packages/todo/infrastructure", "apps/todo-cli", ] as const +const workspaceRootFiles = [ + "nx.json", + "package.json", + "pnpm-workspace.yaml", + "tsconfig.build.json", + "vitest.config.mts", +] const modules = () => Effect.all({ ApplyPlan: Effect.promise(() => import(new URL("../src/apply-plan.js", import.meta.url).href)), + Generation: Effect.promise(() => import("@effectify/app-builder-generation")), Planner: Effect.promise( () => import(new URL("../../generation/src/planner.js", import.meta.url).href), ), + Public: Effect.promise(() => import(new URL("../src/index.js", import.meta.url).href)), TodoPreset: Effect.promise( () => import(new URL("../../generation/src/todo-preset.js", import.meta.url).href), ), @@ -31,17 +54,27 @@ const canonicalPlan = (Planner: PlannerModule) => capabilities: ["todo.events"], }) -const isAllowedPath = (path: string): boolean => roots.some((root) => path.startsWith(`${root}/`)) +const isAllowedPath = (path: string): boolean => + workspaceRootFiles.includes(path) || roots.some((root) => path.startsWith(`${root}/`)) it.effect("S05 and R06 apply an approved Todo plan through Tree with exactly four roots", () => Effect.gen(function* () { - const { ApplyPlan, Planner } = yield* modules() - const tree = createTreeWithEmptyWorkspace() - const result = yield* ApplyPlan.applyTodoPlan(tree, yield* canonicalPlan(Planner)) + const { ApplyPlan, Generation, Planner } = yield* modules() + const tree = createTree() + const plan = yield* canonicalPlan(Planner) + const result = yield* ApplyPlan.applyTodoPlan(tree, plan) + const direct = yield* Generation.TodoGeneration.composeTodoAtomic(Generation.TodoPreset.DefaultTodoRenderContext) + const rootPaths = new Set(Generation.TodoGeneration.WorkspaceRootFiles) + const rootFiles = direct.contributions.filter((file) => rootPaths.has(file.path)) expect(result.generatedRoots).toEqual(roots) expect(result.writtenPaths).not.toHaveLength(0) expect(result.writtenPaths.every(isAllowedPath)).toBe(true) + expect(rootFiles).toHaveLength(5) + expect(result.writtenPaths).toEqual(expect.arrayContaining(rootFiles.map((file) => file.path))) + for (const file of rootFiles) { + expect(tree.read(file.path, "utf8")).toBe(new TextDecoder().decode(file.bytes)) + } for (const root of roots) { expect(tree.exists(`${root}/package.json`)).toBe(true) expect(tree.exists(`${root}/src/index.ts`)).toBe(true) @@ -49,10 +82,67 @@ it.effect("S05 and R06 apply an approved Todo plan through Tree with exactly fou }), ) +it.effect("materializes custom public naming through real Nx generateFiles", () => { + 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" }, + }, + } + return Effect.gen(function* () { + const { ApplyPlan, Planner, TodoPreset } = yield* modules() + const plan = yield* Planner.planTodo(intent) + const topology = yield* TodoPreset.createTodoTopology(plan) + const tree = createTree() + const result = yield* ApplyPlan.applyTodoPlan(tree, plan) + expect(result.writtenPaths).toEqual(topology.files.map(({ path }) => path)) + expect(tree.read("apps/admin-console/src/index.ts", "utf8")).toContain("TaskApplication") + expect(tree.read("packages/operations/domain/package.json", "utf8")).toContain("@acme/operations-domain") + }) +}) + +it.effect("accepts only canonical workspace-root files before mutating the Tree", () => + Effect.gen(function* () { + const { ApplyPlan, Planner, TodoPreset } = yield* modules() + const tree = createTree() + const topology = yield* TodoPreset.createTodoTopology(yield* canonicalPlan(Planner)) + + const failure = yield* ApplyPlan.applyTodoTopology(tree, { + ...topology, + files: [...topology.files, { content: "untrusted root file\n", owner: "test", path: "README.md" }], + }).pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "TodoTopologyApplyError", path: "README.md", reason: "ownership-conflict" }) + expect(tree.exists("nx.json")).toBe(false) + }), +) + +it.effect("generic Nx planning behaviorally composes actual surface catalogs", () => + Effect.gen(function* () { + const { Generation, Public } = yield* modules() + for (const [scope, workspace] of [ + ["@acme", "task-workspace"], + ["@globex", "console"], + ]) { + const options = surfaceRequest(Generation, scope, workspace) + const direct = yield* Generation.composeCatalog(options) + const adapter = yield* Public.composeGeneration(options) + + expect(adapter).toEqual(direct) + } + }), +) + it.effect("S06 reapplies the same canonical Todo plan without changing generated files", () => Effect.gen(function* () { const { ApplyPlan, Planner } = yield* modules() - const tree = createTreeWithEmptyWorkspace() + const tree = createTree() const plan = yield* canonicalPlan(Planner) yield* ApplyPlan.applyTodoPlan(tree, plan) const before = roots.map((root) => tree.read(`${root}/package.json`, "utf8")) @@ -66,7 +156,7 @@ it.effect("S06 reapplies the same canonical Todo plan without changing generated it.effect("S07 rejects an existing unowned target before any Tree mutation", () => Effect.gen(function* () { const { ApplyPlan, Planner } = yield* modules() - const tree = createTreeWithEmptyWorkspace() + const tree = createTree() const path = "packages/todo/domain/package.json" const userContent = '{"name":"user-authored-domain"}\n' tree.write(path, userContent) @@ -82,7 +172,7 @@ it.effect("S07 rejects an existing unowned target before any Tree mutation", () it.effect("S08 rejects duplicate ownership claims before applying any files", () => Effect.gen(function* () { const { ApplyPlan, Planner, TodoPreset } = yield* modules() - const tree = createTreeWithEmptyWorkspace() + const tree = createTree() const topology = yield* TodoPreset.createTodoTopology(yield* canonicalPlan(Planner)) const [first] = topology.files if (first === undefined) { @@ -119,7 +209,7 @@ it.effect("S10 emits the inward-only Todo dependency direction", () => it.effect("S11 rejects outward Domain dependencies before any Tree mutation", () => Effect.gen(function* () { const { ApplyPlan, Planner, TodoPreset } = yield* modules() - const tree = createTreeWithEmptyWorkspace() + const tree = createTree() const topology = yield* TodoPreset.createTodoTopology(yield* canonicalPlan(Planner)) const [domain, ...remaining] = topology.projects if (domain === undefined) { diff --git a/packages/app-builder/nx-plugin/tsconfig.lib.json b/packages/app-builder/nx-plugin/tsconfig.lib.json index 724cdca8..993b2290 100644 --- a/packages/app-builder/nx-plugin/tsconfig.lib.json +++ b/packages/app-builder/nx-plugin/tsconfig.lib.json @@ -5,7 +5,7 @@ "rootDir": "./src", "lib": ["ES2022"], "target": "ES2022", - "types": [] + "types": ["node"] }, "include": ["src/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3f68f07..34b3c199 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -807,6 +807,28 @@ importers: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.25)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@20.19.25)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.0)(yaml@2.9.0)) + packages/app-builder/e2e: + dependencies: + '@effectify/app-builder-generation': + specifier: workspace:* + version: link:../generation + effect: + specifier: 4.0.0-beta.102 + version: 4.0.0-beta.102 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.25 + nx: + specifier: 23.1.0 + version: 23.1.0(@swc-node/register@1.11.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@swc/core@1.15.43(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43(@swc/helpers@0.5.23)) + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@20.19.25)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.3(@types/node@20.19.25)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.2)(tsx@4.23.0)(yaml@2.9.0)) + packages/app-builder/execution: dependencies: '@effectify/app-builder-contracts': @@ -837,9 +859,15 @@ importers: packages/app-builder/generation: dependencies: + '@effectify/app-builder-contracts': + specifier: workspace:* + version: link:../contracts effect: specifier: 'catalog:' version: 4.0.0-beta.102 + ejs: + specifier: 5.0.1 + version: 5.0.1 tslib: specifier: 'catalog:' version: 2.8.1 @@ -850,6 +878,9 @@ importers: '@nx/devkit': specifier: 23.1.0 version: 23.1.0(nx@23.1.0(@swc-node/register@1.11.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@swc/core@1.15.43(@swc/helpers@0.5.23))(@swc/types@0.1.27)(typescript@6.0.3))(@swc/core@1.15.43(@swc/helpers@0.5.23))) + '@types/ejs': + specifier: 3.1.5 + version: 3.1.5 '@types/node': specifier: 'catalog:' version: 20.19.25 @@ -6314,6 +6345,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/ejs@3.1.5': + resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -14305,12 +14339,12 @@ snapshots: dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 + optional: true '@emnapi/core@1.11.2': dependencies: '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 - optional: true '@emnapi/core@1.4.5': dependencies: @@ -14320,11 +14354,11 @@ snapshots: '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 + optional: true '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 - optional: true '@emnapi/runtime@1.4.5': dependencies: @@ -15571,8 +15605,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.4': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 '@tybys/wasm-util': 0.9.0 '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': @@ -18501,6 +18535,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/ejs@3.1.5': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b3b9f546..355a51d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - apps/* + - "!examples/**" - packages/* - packages/*/* - "!packages/*/dist" diff --git a/tools/check-app-builder-showcase.mjs b/tools/check-app-builder-showcase.mjs new file mode 100644 index 00000000..34684871 --- /dev/null +++ b/tools/check-app-builder-showcase.mjs @@ -0,0 +1,185 @@ +import { createHash } from "node:crypto" +import { spawnSync } from "node:child_process" +import { mkdtemp, mkdir, readFile, readdir, rm, stat } from "node:fs/promises" +import { basename, dirname, join, relative, resolve } from "node:path" +import { tmpdir } from "node:os" +import { fileURLToPath } from "node:url" + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const packageRoots = ["contracts", "generation", "cli"].map((name) => + join(repositoryRoot, "packages/app-builder", name), +) + +const option = (name) => { + const index = process.argv.indexOf(name) + return index === -1 ? undefined : process.argv[index + 1] +} + +const expectedArgument = option("--expected") +const outputArgument = option("--output") + +if (expectedArgument === undefined || outputArgument === undefined) { + throw new Error("Usage: node tools/check-app-builder-showcase.mjs --expected --output ") +} + +const expected = resolve(repositoryRoot, expectedArgument) +const output = resolve(outputArgument) +const outputRelativeToExpected = relative(expected, output) +const expectedRelativeToOutput = relative(output, expected) + +if ( + expected === output || + (!outputRelativeToExpected.startsWith("..") && outputRelativeToExpected.length > 0) || + (!expectedRelativeToOutput.startsWith("..") && expectedRelativeToOutput.length > 0) +) { + throw new Error("The isolated output path must not be the committed showcase or one of its descendants") +} + +const run = (command, args, options = {}, allowedStatuses = [0]) => { + const result = spawnSync(command, args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024, ...options }) + if (result.error !== undefined) throw result.error + if (!allowedStatuses.includes(result.status ?? 1)) { + throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr}${result.stdout}`) + } + return result +} + +const walk = async (root, current = root) => + ( + await Promise.all( + (await readdir(current, { withFileTypes: true })).map((entry) => { + const absolute = join(current, entry.name) + return entry.isDirectory() ? walk(root, absolute) : Promise.resolve([relative(root, absolute)]) + }), + ) + ) + .flat() + .sort() + +const sha256 = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}` +const mode = async (path) => (0o100000 | ((await stat(path)).mode & 0o777)).toString(8) + +const readTerminal = (stdout) => { + const event = JSON.parse(stdout.trim().split("\n").at(-1) ?? "null") + if (event?.terminal?._tag !== "Success" || event.terminal.command !== "generate") { + throw new Error("Installed public CLI did not return generate success terminal evidence") + } + return event.terminal.result +} + +const invoke = (cli, parent, request) => { + const result = run(cli, ["generate", "--events=jsonl"], { + cwd: parent, + input: `${JSON.stringify(request)}\n`, + }) + if (result.stderr !== "") throw new Error(`Installed public CLI wrote unexpected stderr: ${result.stderr}`) + return readTerminal(result.stdout) +} + +const receipt = JSON.parse(await readFile(join(expected, ".effectify/app-builder/showcase.json"), "utf8")) +const identities = receipt?.provenance?.outputIdentities + +if ( + receipt?.version !== "effectify.app-builder-showcase/1" || + receipt?.command !== "generate" || + !Array.isArray(identities) || + identities.length === 0 +) { + throw new Error("Committed showcase receipt does not contain canonical output identities") +} + +const expectedPaths = identities.map(({ path }) => path).sort() +if (new Set(expectedPaths).size !== expectedPaths.length) throw new Error("Showcase receipt contains duplicate paths") +const authoredPaths = [".effectify/app-builder/showcase.json", "README.md"] +const request = { + ...receipt.request, + payload: { ...receipt.request.payload, workspace: basename(output) }, +} + +const verify = async (root, allowedExtraPaths = []) => { + const actualPaths = (await walk(root)).sort() + const unexpected = actualPaths.filter((path) => !expectedPaths.includes(path) && !allowedExtraPaths.includes(path)) + const missing = expectedPaths.filter((path) => !actualPaths.includes(path)) + const failures = [...unexpected.map((path) => `unexpected output: ${path}`), ...missing.map((path) => `missing output: ${path}`)] + + for (const identity of identities) { + if (missing.includes(identity.path)) continue + const target = join(root, identity.path) + const bytes = await readFile(target) + if (sha256(bytes) !== identity.sourceDigest) failures.push(`digest mismatch: ${identity.path}`) + if ((await mode(target)) !== identity.mode) failures.push(`mode mismatch: ${identity.path}`) + } + + return failures +} + +const temporaryRoot = await mkdtemp(join(tmpdir(), "effectify-app-builder-showcase-")) +try { + const distribution = join(temporaryRoot, "distribution") + const driver = join(temporaryRoot, "driver") + await mkdir(distribution, { recursive: true }) + await mkdir(driver, { recursive: true }) + + for (const packageRoot of packageRoots) { + run("pnpm", ["pack", "--pack-destination", distribution], { cwd: packageRoot }) + } + + const tarballs = (await readdir(distribution)) + .filter((path) => path.endsWith(".tgz")) + .sort() + .map((path) => join(distribution, path)) + if (tarballs.length !== packageRoots.length) throw new Error("Public package packing did not produce three tarballs") + + const workspaceCatalog = await readFile(join(expected, "pnpm-workspace.yaml"), "utf8") + const effectVersion = /^\s*effect:\s*([^\s]+)$/m.exec(workspaceCatalog)?.[1] + if (effectVersion === undefined) throw new Error("Generated workspace does not pin Effect in its catalog") + + run("npm", ["install", "--no-audit", "--no-fund", ...tarballs, `effect@${effectVersion}`], { cwd: driver }) + const cli = join(driver, "node_modules/.bin/effectify-app-builder") + + await rm(output, { force: true, recursive: true }) + await mkdir(dirname(output), { recursive: true }) + const generated = invoke(cli, dirname(output), request) + const writtenPaths = [...generated.writtenPaths].sort() + if (JSON.stringify(writtenPaths) !== JSON.stringify(expectedPaths)) { + throw new Error(`Installed public CLI wrote an unexpected path set: ${JSON.stringify(writtenPaths)}`) + } + + const generatedFailures = await verify(output) + const expectedFailures = await verify(expected, authoredPaths) + const byteFailures = [] + for (const path of expectedPaths) { + const expectedBytes = await readFile(join(expected, path)) + const generatedBytes = await readFile(join(output, path)) + if (!expectedBytes.equals(generatedBytes)) byteFailures.push(path) + } + + const replay = invoke(cli, dirname(output), request) + if (replay.writtenPaths.length !== 0) { + throw new Error(`Installed public replay wrote paths: ${JSON.stringify(replay.writtenPaths)}`) + } + const replayFailures = await verify(output) + const failures = [...generatedFailures, ...expectedFailures, ...replayFailures] + + if (failures.length > 0 || byteFailures.length > 0) { + process.stderr.write("App Builder showcase drift detected; committed files were not modified.\n") + for (const failure of failures) process.stderr.write(`${failure}\n`) + for (const path of byteFailures) { + const diff = run( + "git", + ["diff", "--no-index", "--no-ext-diff", "--", join(expected, path), join(output, path)], + {}, + [0, 1], + ) + process.stderr.write(diff.stdout) + process.stderr.write(diff.stderr) + } + process.exitCode = 1 + } else { + process.stdout.write( + `App Builder showcase matches ${expectedPaths.length} installed-public-CLI output identities and replays with zero writes.\n`, + ) + } +} finally { + await rm(temporaryRoot, { force: true, recursive: true }) +}