Files
agentic-dev/docs/superpowers/specs/2026-05-09-core-package-generator-design.md
Danijel Martinek 26374253de 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) <noreply@anthropic.com>
2026-05-09 11:57:46 +02:00

21 KiB
Raw Blame History

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<X> / bindDevSeed<X> 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 <name>" 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

// packages/core-shared/src/di/bind-protocols.ts
export type EventBusProtocol = {
  publish<T>(event: { name: string }, payload: T): Promise<void>;
  subscribe<T>(
    event: { name: string },
    consumer: string,
    handler: (payload: T) => Promise<void>,
  ): void;
};

export type RealtimeBroadcasterProtocol = {
  broadcast<T>(
    channel: { name: string; key?: unknown },
    payload: T,
  ): Promise<void>;
};

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

// 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<Bus, Realtime, RealtimeReg> & {
  config: SanitizedConfig;
};

SanitizedConfig comes from payload — already a transitive dependency of core-shared.

4.3 Optional packages adopt the protocol

// 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 */ }
// 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 */ }
// 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:

export function bindProductionAuth(
  config: SanitizedConfig,
  tracer: ITracer,
  logger: ILogger,
  bus: IEventBus,
  queue: IJobQueue,
  realtime: IRealtimeBroadcaster,
  realtimeRegistry: IRealtimeHandlerRegistry,
): void { /* ... */ }

After:

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:

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<IEventBus> 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.

// 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/<pkg>/** into turbo/generators/templates/core-package/<pkg>/ 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:

    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 <name>. 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 // <gen:realtime-rules-imports> and // <gen:realtime-rules> remain
3 (No separate files) E1 / J no-restricted-syntax blocks stripped; anchor // <gen:events-rules> 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 <name> 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/<name>.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):

git clone <slimmed-template> /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.
  • 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