From 26374253de40c0f72e6e084523990de0b005c8d6 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Sat, 9 May 2026 11:57:46 +0200 Subject: [PATCH] docs(spec): core-package generator + template slimming design 6-phase design for slimming the default template to a minimal kernel and providing turbo generators that scaffold optional core packages (realtime, events, trpc, ui) back when needed. Phase 0 introduces a BindContext object in core-shared with bounded generics over minimal protocol types; optional packages keep their full interfaces but extends-link to the protocols. This lets feature binders take a single ctx arg and lets optional packages disappear without breaking typechecks. Phases 1-5 ship the generator framework + per- package templates + per-package removal from main. Companion ADR will be assigned at implementation time (expected ADR-017). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-09-core-package-generator-design.md | 399 ++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-09-core-package-generator-design.md diff --git a/docs/superpowers/specs/2026-05-09-core-package-generator-design.md b/docs/superpowers/specs/2026-05-09-core-package-generator-design.md new file mode 100644 index 0000000..c519aae --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-core-package-generator-design.md @@ -0,0 +1,399 @@ +# Core-package generator + template slimming — Design + +**Date:** 2026-05-09 +**Status:** Draft (pending user review) +**Companion ADR:** TBD (numbering tracked at implementation time; expected ADR-017). +**Builds on:** ADR-008 (per-feature DI containers), ADR-014 (instrumentation), ADR-015 (events and jobs), ADR-016 (realtime layer). + +--- + +## 1. Context and motivation + +This monorepo is intended as a reusable starter template for new projects. Today every new project inherits **all** core packages whether or not it needs them — including `core-realtime` (Socket.IO), `core-events` (cross-feature event bus), `core-trpc` (tRPC server setup), and `core-ui` (design system). For projects that don't ship realtime, don't need cross-feature events, prefer plain server actions over tRPC, or import their design system from outside, those packages are dead weight. + +We want the template's defaults to be the **minimal viable kernel** and provide turbo generators that **scaffold the optional packages back** when a project needs them. The generators take their templates from the *current* shipped packages, so a generator-scaffolded `core-realtime` is byte-identical to today's `packages/core-realtime/`. + +Two architectural realities make this non-trivial: + +- **Feature binders thread cross-cutting deps positionally.** Every feature's `bindProductionX` / `bindDevSeedX` takes a 7-arg positional signature including `bus, queue, realtime, realtimeRegistry`. Removing core-events or core-realtime breaks every feature's signature. +- **Optional packages own contracts that other code depends on at compile time.** `IEventBus` and `IRealtimeBroadcaster` are referenced in feature bind files. Their absence breaks typechecking unless something else owns the type. + +Phase 0 of this design solves both via a `BindContext` object with **minimal protocol types in `core-shared`** and **full interfaces remaining in optional packages**. After Phase 0, removing an optional package is a leaf operation: typing falls back to the protocol default, runtime guards (`ctx.realtime?.broadcast(...)`) handle absence. + +## 2. Decision summary + +1. Introduce a `BindContext` type in `core-shared` with required (`tracer`, `logger`, `config`) and optional (`bus`, `queue`, `realtime`, `realtimeRegistry`) fields. Optional fields use **bounded generics** over minimal protocol types (`EventBusProtocol`, `RealtimeBroadcasterProtocol`, `RealtimeRegistryProtocol`) defined in `core-shared`. +2. Optional packages keep their full interfaces (`IEventBus`, `IRealtimeBroadcaster`, etc.) but declare them `extends` the corresponding protocol — typechecks fail if a refactor breaks the protocol surface. +3. Convert all 5 features' `bindProduction` / `bindDevSeed` from positional args to single-arg `(ctx: BindContext)`. App aggregator binders construct the context once. +4. Add `pnpm turbo gen core-package` generator with a `name` prompt selecting one of `{realtime, events, trpc, ui}`. Each name maps to a verbatim template directory + a generator action that scaffolds the package, updates the consuming apps' `transpilePackages` list, and patches `core-eslint/base.js` for any rules that need re-adding. +5. Once a generator works, **remove that package from `main`** along with its consuming-app wiring and its ESLint rules (per the user's preference for "remove + re-add"). The `main` template ends in a slimmed state. +6. Per-package documentation (ADR-016, realtime guide, etc.) stays in `docs/` permanently with a "Status: optional, scaffolded via `gen core-package `" header. HTML explainers (`data-flow-explainer.html`, `di-explainer.html`) get conditional/dashed rendering for optional packages. + +## 3. Architecture overview + +Five sequential, independently mergeable phases: + +| Phase | Ships | Verification | +|---|---|---| +| 0 | `BindContext` type, all binders + app aggregators on single-arg `ctx`, doc + HTML updates for the binder change | Behavior unchanged; all CI gates green | +| 1 | `pnpm turbo gen core-package` framework — entry, prompt, dispatch table — but no templates yet | `core-package` registered with empty `choices`; framework no-op | +| 2 | core-realtime: capture template → ship generator action → remove from `main` → ESLint rules excised | From slim tree, `gen core-package realtime` produces byte-identical reconstruction | +| 3 | core-events: same lifecycle | same | +| 4 | core-trpc: same | same | +| 5 | core-ui: same | same | + +Plus a final cross-doc sweep + end-to-end "scaffold everything from slim main" acceptance test. + +## 4. Phase 0 — BindContext refactor + +### 4.1 Protocol types in core-shared + +```ts +// packages/core-shared/src/di/bind-protocols.ts +export type EventBusProtocol = { + publish(event: { name: string }, payload: T): Promise; + subscribe( + event: { name: string }, + consumer: string, + handler: (payload: T) => Promise, + ): void; +}; + +export type RealtimeBroadcasterProtocol = { + broadcast( + channel: { name: string; key?: unknown }, + payload: T, + ): Promise; +}; + +export type RealtimeRegistryProtocol = { + register(entry: { descriptor: unknown; handler: unknown }): void; + registerChannel(descriptor: unknown): void; + listChannels(): unknown[]; +}; +``` + +Each protocol captures only what feature binders touch. Adapter-specific methods stay on the full interfaces in optional packages. + +### 4.2 BindContext type + +```ts +// packages/core-shared/src/di/bind-context.ts +import type { ITracer, ILogger } from "../instrumentation"; +import type { IJobQueue } from "../jobs"; +import type { + EventBusProtocol, + RealtimeBroadcasterProtocol, + RealtimeRegistryProtocol, +} from "./bind-protocols"; + +export type BindContextBase = { + tracer: ITracer; + logger: ILogger; +}; + +export type BindContext< + Bus extends EventBusProtocol = EventBusProtocol, + Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol, + RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol, +> = BindContextBase & { + bus?: Bus; + queue?: IJobQueue; + realtime?: Realtime; + realtimeRegistry?: RealtimeReg; +}; + +// Production extends with config; dev-seed does not. +export type BindProductionContext< + Bus extends EventBusProtocol = EventBusProtocol, + Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol, + RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol, +> = BindContext & { + config: SanitizedConfig; +}; +``` + +`SanitizedConfig` comes from `payload` — already a transitive dependency of `core-shared`. + +### 4.3 Optional packages adopt the protocol + +```ts +// packages/core-events/src/event-bus.interface.ts (1-line change) +import type { EventBusProtocol } from "@repo/core-shared/di/bind-protocols"; +export interface IEventBus extends EventBusProtocol { /* existing methods */ } +``` + +```ts +// packages/core-realtime/src/realtime-broadcaster.interface.ts (1-line change) +import type { RealtimeBroadcasterProtocol } from "@repo/core-shared/di/bind-protocols"; +export interface IRealtimeBroadcaster extends RealtimeBroadcasterProtocol { /* existing */ } +``` + +```ts +// packages/core-realtime/src/realtime-handler-registry.ts (1-line change to interface) +import type { RealtimeRegistryProtocol } from "@repo/core-shared/di/bind-protocols"; +export interface IRealtimeHandlerRegistry extends RealtimeRegistryProtocol { /* existing */ } +``` + +The `extends` link forces a typecheck failure if a future refactor breaks the protocol surface — the safety net behind keeping interfaces in optional packages. + +### 4.4 Feature binder migration + +All 10 binders (5 features × `bindProduction` + `bindDevSeed`) get converted from positional to context arg: + +**Before:** + +```ts +export function bindProductionAuth( + config: SanitizedConfig, + tracer: ITracer, + logger: ILogger, + bus: IEventBus, + queue: IJobQueue, + realtime: IRealtimeBroadcaster, + realtimeRegistry: IRealtimeHandlerRegistry, +): void { /* ... */ } +``` + +**After:** + +```ts +export function bindProductionAuth(ctx: BindProductionContext): void { + const { config, tracer, logger, bus, realtime, realtimeRegistry } = ctx; + // body unchanged otherwise; optional fields become guarded: + bus?.subscribe(/* ... */); + realtimeRegistry?.register(/* ... */); +} +``` + +Same for `bindDevSeedAuth(ctx: BindContext)` (no `config` field). + +### 4.5 App aggregator migration + +`apps/web-next/src/server/bind-production.ts` builds the context once: + +```ts +import type { IEventBus } from "@repo/core-events"; +import type { + IRealtimeBroadcaster, + IRealtimeHandlerRegistry, +} from "@repo/core-realtime"; + +const ctx: BindProductionContext< + IEventBus, + IRealtimeBroadcaster, + IRealtimeHandlerRegistry +> = { + config: resolvedConfig, + tracer, + logger, + bus, + queue, + realtime, + realtimeRegistry, +}; +await bindProductionAuth(ctx); +await bindProductionBlog(ctx); +// ... all five +``` + +Same in `bindAllDevSeed` (no `config`). + +**Generic args evolve per phase.** The example above assumes core-events and core-realtime are present (Phase 0 state). After Phase 2 (core-realtime removal), the imports and the second/third generic args are dropped — `BindProductionContext` falls back to the protocol defaults for the missing slots. After Phase 3 (core-events removal), it's `BindProductionContext` with no generic args. The generator's per-phase actions edit this aggregator file accordingly. + +### 4.6 Phase 0 file budget + +~12-15 files touched: 2 new in core-shared, 3 minor `extends` changes in optional packages, 10 binder rewrites, 2 app aggregator updates, 1 test file update, doc + HTML refreshes. + +## 5. Phase 1 — Generator framework + +Smallest possible scaffold; no templates yet. + +```ts +// turbo/generators/config.ts (additions) +const CORE_PACKAGE_GENERATORS: Record< + string, + (a: { name: string }) => PlopTypes.ActionType[] +> = { + // Phases 2-5 each insert one entry here. +}; + +plop.setGenerator("core-package", { + description: "Scaffold an optional core package (realtime, events, trpc, ui)", + prompts: [ + { + type: "list", + name: "name", + message: "Which optional core package?", + choices: [], // Phases 2-5 push entries here. + }, + ], + actions: (answers) => { + const a = answers as { name: string }; + const handler = CORE_PACKAGE_GENERATORS[a.name]; + if (!handler) throw new Error(`No generator for core-package '${a.name}'`); + return handler(a); + }, +}); +``` + +Plus: + +- `turbo/generators/templates/core-package/` directory (empty) +- `turbo/generators/lib/core-package-utils.ts` — shared helpers: `assertOptionalPackageNotPresent(name)`, `addToTranspilePackages(nextConfigPath, pkgName)`, `splicePluginRulesAt(baseJsPath, anchorName, ruleBlock)` +- `turbo/generators/config.test.ts` — confirms `core-package` registered with empty `choices` (regression baseline for phases 2-5) + +No anchor protocol added in this phase. Optional core packages don't have sub-generators; anchors aren't needed beyond what's already used in the existing feature/realtime/event/job generators. + +## 6. Phases 2-5 — Per-package lifecycle + +Each per-package phase follows a 4-step lifecycle: + +1. **Capture template** — copy `packages//**` into `turbo/generators/templates/core-package//` as `.hbs` siblings. Files are verbatim; `.hbs` extension is mechanical (plop requirement). Package name is **not** parameterized — these are concrete preserved templates. + +2. **Generator action** — push entry into Phase 1's dispatch table. Each entry returns the actions array: + + ```ts + realtime: () => [ + () => assertOptionalPackageNotPresent("core-realtime"), + ...emitTemplateTree("core-package/realtime", "packages/core-realtime"), + () => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-realtime"), + // Phase 2 only — explicit boundaries entry needed for mode: "folder": + () => addBoundariesEntry("packages/core-realtime", { mode: "folder" }), + // Phase 2 only — re-add ESLint rule files + base.js plugin block: + ...emitRealtimeESLintRules(), + () => splicePluginRulesAt("packages/core-eslint/base.js", "realtime-rules-imports", REALTIME_IMPORTS), + () => splicePluginRulesAt("packages/core-eslint/base.js", "realtime-rules", REALTIME_PLUGIN_BLOCK), + () => printRealtimeNextSteps(), + ], + ``` + +3. **Remove from main** — delete the package; remove the consuming-app wiring (`transpilePackages`, app `server.ts` bootstrap, etc.); remove the package's ESLint rules (per "remove + re-add" choice). Anchors stay in `base.js` even when empty. + +4. **Verify byte-identical reconstruction** — from the slim tree, run `pnpm turbo gen core-package `. Compare result against snapshot. Run all CI gates. Must be green. + + "Byte-identical" allows: plop's `.hbs` extension is stripped from emitted file paths; trailing-newline normalization (LF, single trailing newline). Disallows any other content drift. The snapshot stores sha256 of post-normalization content. + +### 6.1 Per-package variations + +| Phase | Package | Special generator actions | Special removal actions | +|---|---|---|---| +| 2 | core-realtime | Add `mode: "folder"` boundaries entry (placed before the `packages/core-*` wildcard); emit ESLint rules; splice base.js | Strip ESLint rule files + base.js block; strip realtime bootstrap from `apps/web-next/server.ts`; strip realtime params from app aggregator | +| 3 | core-events | Splice the inline `no-restricted-syntax` block into base.js for E1 + J | Strip the block from base.js; in `apps/web-next/src/server/bind-production.ts` drop the `bus` and `queue` construction and narrow the `BindProductionContext` generics | +| 4 | core-trpc | Mount tRPC root in `apps/web-next` and `apps/web-tanstack` (manual print step — generator doesn't auto-edit app code) | Strip tRPC root mounting from both apps | +| 5 | core-ui | Add to Storybook stories list; add to `apps/web-next/next.config.mjs` `transpilePackages`; add to `apps/web-tanstack/vite.config.ts` (workspace package handling) | Strip Storybook story registrations; remove from `apps/web-next/next.config.mjs` and `apps/web-tanstack/vite.config.ts` | + +### 6.2 ESLint rule placement after each removal + +| Phase | After removal: rule files | After removal: base.js | +|---|---|---| +| 2 | `packages/core-eslint/rules/no-direct-socket-io.{js,test.js}` and `no-realtime-handler-reexport.{js,test.js}` deleted | Imports stripped; `repo-rules` plugin block at end of base.js stripped; anchors `// ` and `// ` remain | +| 3 | (No separate files) | E1 / J `no-restricted-syntax` blocks stripped; anchor `// ` remains | +| 4 | n/a | n/a (no trpc-specific lint rules) | +| 5 | n/a | n/a (no ui-specific lint rules) | + +### 6.3 Per-phase printed next-steps + +Each generator prints copy-paste-ready manual wiring steps after running. The realtime example: + +``` +───────────────────────────────────────────────────────────── +core-realtime scaffolded into packages/core-realtime/. + +Manual wiring required: + +1. apps/web-next/server.ts — boot Socket.IO + pass into bindAll: + import { Server as IOServer } from "socket.io"; + import { + RealtimeHandlerRegistry, + SocketIORealtimeBroadcaster, + SocketIORealtimeServer, + } from "@repo/core-realtime"; + const io = new IOServer(httpServer); + const realtime = new SocketIORealtimeBroadcaster(io); + const realtimeRegistry = new RealtimeHandlerRegistry(); + await bindAll({ realtime, realtimeRegistry }); + // ...authenticator + SocketIORealtimeServer.start() ... + +2. Feature bind-production.ts files publishing/subscribing via realtime + continue to read ctx.realtime / ctx.realtimeRegistry — no changes + needed in features (Phase 0 already wired this). + +3. Verify: + pnpm install + pnpm lint && pnpm typecheck && pnpm test + pnpm turbo boundaries +───────────────────────────────────────────────────────────── +``` + +Equivalent print blocks for events / trpc / ui — each tailored to that package's bootstrap requirements. + +## 7. Testing strategy + +Three layers, common to phases 2-5: + +1. **Generator-emit unit test** — calls the generator action and asserts emitted file paths against the template tree. No plop integration required. Lives in `turbo/generators/config.test.ts` (per-phase additions). +2. **Generator e2e test** — programmatically runs `turbo gen core-package ` against a tmp workspace, then runs `pnpm install + lint + typecheck + test + boundaries` from the tmp workspace. ~10-15s per package; runs in CI. +3. **Byte-identical reconstruction test** — compares the post-generator package tree against a snapshot at `turbo/generators/__snapshots__/core-package/.snapshot.json` (file paths + sha256 of each file's contents). Generator output must match the snapshot. Snapshots are committed alongside templates so review catches unintended drift. + +Phase 0 specifically: + +- `core-shared/src/di/bind-protocols.test.ts` — assignability assertion that `IEventBus`, `IRealtimeBroadcaster`, `IRealtimeHandlerRegistry` are assignable to their respective protocols +- `apps/web-next/src/server/bind-production.test.ts` — updated to call binders with `ctx` arg +- Each feature's `bind-dev-seed.test.ts` — updated to construct `ctx` and pass + +Phase 1 specifically: + +- `turbo/generators/config.test.ts` — asserts `core-package` generator registered with empty `choices` (baseline for later phases) + +End-to-end acceptance test (final, after Phase 5): + +```bash +git clone /tmp/test-project +cd /tmp/test-project +pnpm turbo gen core-package realtime +pnpm turbo gen core-package events +pnpm turbo gen core-package trpc +pnpm turbo gen core-package ui +pnpm install && pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries +# Result must match pre-slim main byte-identical (modulo lockfile order) +``` + +## 8. Documentation & HTML updates + +Threaded into each phase, plus a final cross-doc sweep. + +| Phase | Doc updates | HTML updates | +|---|---|---| +| 0 | `docs/architecture/dependency-flow.md` (BindContext shape replaces positional args); `docs/architecture/vertical-feature-spec.md` (binder signature section); per-feature `AGENTS.md` (binder examples); `CLAUDE.md` (binder rule text) | `docs/architecture/di-explainer.html` (DI binder visualization), `docs/architecture/data-flow-explainer.html` (binder param flow) | +| 1 | New `docs/scaffolding/core-package-generator.md`; `CLAUDE.md` Quick Start adds `pnpm turbo gen core-package`; `AGENTS.md` mentions the new generator alongside existing four | none | +| 2 | ADR-016 gets "Status: optional, scaffolded via `gen core-package realtime`" header; `docs/guides/realtime.md` adds prerequisite note | `data-flow-explainer.html` realtime arrows marked conditional (dashed lines, "optional" tag) | +| 3 | ADR-015 same treatment; `docs/guides/events-and-jobs.md` prerequisite note | events/bus arrows in `data-flow-explainer.html` marked conditional | +| 4 | tRPC integration docs in feature `AGENTS.md` files note tRPC is optional | tRPC layer in `data-flow-explainer.html` marked conditional | +| 5 | `core-ui` README + Storybook docs note ui is optional | UI layer in `data-flow-explainer.html` marked conditional | + +Final sweep (after Phase 5): + +- New file: `docs/architecture/template-tiers.md` — explains must-have / optional / generator-scaffolded model with the canonical list +- `README.md` (project root) — short "Optional packages" section near Quick Start with the four scaffold commands +- HTML explainers — final pass to confirm conditional/optional markers are visually clear and match the documented model +- All "Read first" lists in `CLAUDE.md` and `AGENTS.md` re-checked for broken references + +## 9. Out of scope + +Explicitly NOT in this design: + +1. **A `core-package remove` generator.** Phases 2-5 do removal once each. Downstream forks remove what they don't need by editing files directly. +2. **Generator templates for new core packages of arbitrary shape** (the `kind`-prompted generic generator). Captured here for posterity; future ADR. +3. **Slimming the must-have set further.** `core-shared`, `core-eslint`, `core-typescript`, `core-testing`, `core-cms`, `core-api` remain unconditionally present. +4. **Slimming the feature set.** All 5 features stay; making them opt-in is a separate (much bigger) design. +5. **Per-app generators.** This work doesn't touch `apps/cms` or `apps/web-tanstack` beyond transpile-package consistency. +6. **Doc relocation automation.** ADRs and guides stay in `docs/` permanently with status headers; the generator does not move documentation files. + +## 10. Related + +- ADR-008 — per-feature DI containers (binder shape rules) +- ADR-014 — instrumentation (where `ITracer` / `ILogger` live in core-shared) +- ADR-015 — events and jobs (introduces `IEventBus` / `IJobQueue`; this design moves the protocol into core-shared) +- ADR-016 — realtime layer (introduces `IRealtimeBroadcaster`; this design moves the protocol into core-shared) +- Memory: "Future capability — core-package generator" (`/Users/danijel/.claude/projects/-Users-danijel-Documents-Projects-template-vertical/memory/project_core_package_generator.md`) — original ask