effx
Specifications

Spec 0003 handoff — CLI and vertical slice (2026-10-03, agent `slice3`)

Bound spec: docs/specs/0003-cli-and-vertical-slice.md (frozen). Re-read it before continuing.

1. Done and committed

Commit 7d63ecf (wip(cli): …, committed by pathspec on the shared tree):

FileContent
docs/specs/0003-cli-and-vertical-slice.mdthe frozen spec (commands, output formats, manifest schema, example layout, e2e test plan, DoD)
packages/cli/src/main.tscomposition root: effect/cli commands check/build/inspect/graph, BunServices.layer + TsSourceFrontend.layer, BunRuntime.runMain; one @effect-diagnostics unstableApiUsage:off with comment
packages/cli/src/commands.tsportable Effects check, build, inspectCommand, graphCommand, resolveProject; errors CheckFailed/UnknownName carry [Runtime.errorReported] = false so runMain exits 1 without re-printing the cause
packages/cli/src/report.tspure diagnostics grouping/formatting (EFFX#### severity message file:line:col, groups by file, (no location) last, summary line)
packages/cli/src/inspect.tspure resolveName (StableId → operation:<name> → unique node by name), renderOperation (research-report tree), schemaDisplay
packages/cli/src/graph.tspure mermaid flowchart LR of the IR or of a node + IRGraph.reachable
packages/cli/src/manifest.tsManifest Schema (effx-manifest v1: compiler versions, semanticHash, generated, diagnostics, locations), ManifestJson (Schema.fromJsonString(…, { space: 2 })), locationsOf (operation ids via Extensions.operationIdOf, model ids via the PersistentModel schema export)
packages/cli/src/index.tsre-exports
packages/compiler/src/Collected.tsDeclaration.location: Schema.optionalKey(Location) (additive)
packages/frontend-ts/src/collect.tsfills location at the four sink.declarations.push sites with positionOf(node/member/declarator)
packages/frontend-ts/src/TsSourceFrontend.tsexport const typescriptVersion = ts.version (manifest field)

Verified: bun --bun node_modules/.bin/tsc --noEmit -p tsconfig.json → exit 0 after the commit's content. Smoke run (not a test): cd packages/frontend-ts/test/fixtures/users && bun ../../../../cli/src/main.ts check → prints grouped diagnostics and 5 error(s), 3 warning(s), 0 info, exit 1 (expected: the fixture's include: src/**/*.ts pulls in broken.ts and both syntaxes → EFFX1001 duplicates; the CLI tests must use explicit files tsconfigs, see §5).

Not run: bun run lint, bun run fmt:check (oxfmt not run on the new files — expect formatting diffs in packages/cli/src/*), bun run typecheck, bun --bun vitest run. The lint agent is concurrently replacing the lint config; coordinate before formatting shared files.

Untracked, pre-existing, not mine: packages/cli/package.json (scaffold from Main; needs committing with the package), examples/users/package.json, docs/research/, the tsconfig.json exclude change.

2. In progress (uncommitted: nothing)

Nothing uncommitted. The next concrete step I was about to take: attach declaration.location to interpreter diagnostics in packages/compiler/src/pipeline.ts interpret (lines 39-41: wrap each contribution's diagnostics with location ?? declaration.location), so EFFX22xx/23xx show file:line:col in effx check instead of landing under (no location). Three-line change; message agent lint first (they own packages/compiler except generate/).

3. Decisions beyond the spec

  • CLI output uses Console.log from effect (the Console reference service, as ai-docs/src/70_cli/10_basics.ts does), not Effect.log: inspect/graph output is the product and must be plain text (mermaid, tree), and Console is swappable in tests. Global console is never used.
  • effx check exit 1 is modelled as a typed failure (CheckFailed) with Runtime.errorReported = false; Runtime.defaultTeardown maps any failure to exit 1 and makeRunMain skips the error log when getErrorReported is false (node_modules/effect/src/Runtime.ts:108-115, 204-214, 374-400). No process.exit.
  • .effx/ = dirname(tsconfig)/.effx, matching the frontend's default outDir so generated import specifiers stay valid.
  • Manifest locations covers operation and model ids only (the only nodes with a declaring syntax); exposures/capabilities derive from the same declaration.
  • Versions: effx from packages/cli/package.json (import cliPackage from "../package.json"), effect from effect/package.json (exported subpath, verified in node_modules/effect/package.json), typescript from the frontend.
  • Spec revision of the cli generator (spec 0003 §"Generated CLI contract"): leaf commands must print the success encoded with Schema.fromJsonString(Success); the current generator discards the result. Not yet implemented (see §5).

4. Gotchas

  • Schema.TaggedError subclasses: overriding [Runtime.errorReported] needs override readonly (TS4114) because YieldableError declares the global marker.
  • effect/cli shapes that typecheck: Command.make(name, { flag }, handler), Flag.String("project").pipe(Flag.withDescription, Flag.withDefault("tsconfig.json")), Argument.String("name").pipe(Argument.withDescription, Argument.optional) → Option<string> in the handler config, Command.withSubcommands([...]), Command.run({ version }) → requires Environment (FileSystem | Path | Terminal | ChildProcessSpawner | Stdio, cli/Command.ts:405) — all provided by BunServices.layer.
  • Bun 1.3.13 lowers TC39 standard decorators natively (verified with a scratch file), so bun src/server-main.ts in examples/users will work without the vitest TS6 plugin.
  • Schema.encode with a Struct ignores excess properties (verified: encoding a full User through User.Public yields only id/displayName), so the generated CLI can print a handler's User via the User.Public success schema.
  • HTTP server wiring (read, not yet exercised): HttpApiBuilder.layer(Api).pipe(Layer.provide(ApiHandlers)); RpcServer.layerHttp({ group: Operations, path: "/rpc", protocol: "http" }) needs RpcSerialization.layerJson + HttpRouter + handlers (rpc/RpcServer.ts:872-897); HttpRouter.serve(app).pipe(Layer.provide(BunHttpServer.layer({ port }))) (http/HttpRouter.ts:1340, BunHttpServer.ts:330). For tests BunHttpServer.layerTest gives an ephemeral-port server plus an HttpClient already prefixed with its origin (BunHttpServer.ts:350-357), which satisfies both the generated Client.layer (HttpApiClient.make(Api) needs HttpClient) and RpcClient.layerProtocolHttp({ url: "/rpc" }) (rpc/RpcClient.ts:1026, prepends the url).
  • Generated code imports schemas with extensionless relative specifiers (../../src/schemas); fine for Bun and moduleResolution: bundler.
  • Root tsconfig.json includes examples/**/*.ts; the example's composition roots import .effx/generated/*.ts (gitignored), so effx build must run before the root tsc gate on a fresh clone. Record this in STATE.md.
  • @PersistentModel({ views: { Public: User.Public } }) would hit the class TDZ (decorator expressions evaluate before the class binding); define UserPublic/UserSelf in schemas.ts and reference those, assign them to static Public/Self.
  • Interpreter diagnostics (EFFX22xx/23xx/1001) currently have no location; see §2.

5. Remaining steps (ordered) with acceptance criteria

  1. Attach locations to interpreter diagnostics (packages/compiler/src/pipeline.ts interpret). Accept: effx check on a project with src/broken.ts prints EFFX2201 error … src/broken.ts:L:C under a file group.
  2. Generator fix: CLI leaf prints result (packages/compiler/src/generate/cli.ts leafLines): handler = ({ input }) => Effect.flatMap(<handler>(input), (result) => Effect.flatMap(Schema.encodeEffect(Schema.fromJsonString(<Success>))(result), Console.log)) (register Effect, Schema, Console imports). Accept: frontend test "generated Effect code typechecks" still exit 0; running the example CLI prints one JSON line.
  3. CLI tests packages/cli/test/cli.test.ts (child process via effect/process ChildProcessSpawner, pattern in packages/frontend-ts/test/frontend.test.ts:308-321, cwd = fixture dir, cmd bun ../../../../cli/src/main.ts …). Write temp tsconfigs into the fixture root with Effect.addFinalizer cleanup: tsconfig.effx-ok.json { extends: "./tsconfig.json", files: ["src/operations.ts"] } and tsconfig.effx-broken.json with files: ["src/broken.ts"]. Accept (spec laws): check ok → exit 0, output contains EFFX0001 and 0 error(s); check broken → exit 1, contains EFFX2201 error; build twice → .effx/ir.json byte-identical, manifest generated = 4 paths, files exist; inspect User.Get → contains Kind, Query, GET /users/:id, RPC User.Get, CLI users get; graph → starts flowchart LR, contains ExposedAs; graph User.Get → no User.ChangeEmail. Add "test" nothing — root vitest picks packages/*/test/**/*.test.ts.
  4. examples/users sources per spec table: tsconfig.json (copy of fixture tsconfig; include: ["src/**/*.ts"], paths not needed — examples/users/node_modules/@effx/runtime is a workspace link), src/schemas.ts, src/user.ts, src/services.ts (Users with static layer over Ref<HashMap> seeded with two users), src/capabilities.ts, src/operations.ts (decorator syntax, @Errors(UserNotFound, EmailTaken) + @Requirements(Users) on changeEmail), src/server.ts (AppRoutes), src/server-main.ts, src/client-main.ts, src/cli-main.ts; package.json scripts effx:build = bun ../../packages/cli/src/main.ts build, serve = bun src/server-main.ts, client = bun src/client-main.ts, cli = bun src/cli-main.ts. Accept: bun run effx:build exit 0 writes .effx/{ir.json,manifest.json,generated/{cli,client,http,rpc}.ts}; root tsc --noEmit clean; bun run serve logs Listening on; bun run client logs both results; bun run cli users get --input '{"id":"u1"}' prints JSON.
  5. e2e test examples/users/test/e2e.test.ts per spec §e2e (build as child process; BunHttpServer.layerTest + AppRoutes; generated client UserGet/UserChangeEmail; Users.find to verify persistence; RpcClient.make(Operations) with RpcClient.layerProtocolHttp({ url: "/rpc" }) + RpcSerialization.layerJson; EmailTaken as a typed failure). Accept: bun --bun vitest run examples/users green.
  6. Gates in this order, once: bun run --cwd examples/users effx:build, bun --bun node_modules/.bin/tsc --noEmit -p tsconfig.json, bun run typecheck, bun run lint, bun run fmt (coordinate with agent lint), bun --bun vitest run. Accept: all clean.
  7. Evidence + STATE.md: wc -l examples/users/src/{operations,schemas,user}.ts vs wc -l examples/users/.effx/generated/*.ts; update the lifecycle table (CLI + examples → build, spec 0003), the evidence table, note SQL persistence deferred (in-memory Users.layer), the effx build-before-tsc prerequisite, and the location gap if step 1 is skipped. Accept: STATE.md rows cite exact commands and results that were actually run.
  8. Commits (conventional, by pathspec): feat(compiler): … for steps 1–2, feat(cli): … for 3 (+ packages/cli/package.json), feat(examples): … for 4–5, docs(state): … for 7.

On this page