diff --git a/docs/architecture/vertical-feature-spec.md b/docs/architecture/vertical-feature-spec.md index f4fabb8..4f2fa9c 100644 --- a/docs/architecture/vertical-feature-spec.md +++ b/docs/architecture/vertical-feature-spec.md @@ -150,77 +150,87 @@ repo/ ## 6. Feature package internal shape -Canonical mature shape (e.g., `packages/blog/`): +Canonical mature shape (e.g., `packages/blog/`) — **post-Plan-9 layout**: ``` packages/blog/ src/ + config.ts # constants if needed + entities/ - article.ts # Zod schema + Article type - article.test.ts - errors.ts + models/ + article.ts # Zod schema + Article type + article.test.ts + errors/ + article.ts # ArticleNotFoundError (sets this.name) + common.ts # InputParseError + errors.test.ts application/ repositories/ - articles-repository.interface.ts # IArticlesRepository + articles.repository.interface.ts # IArticlesRepository use-cases/ - get-article.use-case.ts - get-article.use-case.test.ts + get-articles.use-case.ts # factory + getArticlesInputSchema + getArticlesOutputSchema + parse + get-articles.use-case.test.ts # incl. R25 output-validation test + get-article-by-slug.use-case.ts + get-article-by-slug.use-case.test.ts create-article.use-case.ts + create-article.use-case.test.ts infrastructure/ repositories/ - payload-articles.repository.ts # @injectable, calls getPayload({ config }) from core-cms - mock-articles.repository.ts # @injectable, for tests - payload-articles.repository.test.ts + articles.repository.ts # real Payload-backed impl (getPayload({ config }) from core-cms) + articles.repository.mock.ts # MockArticlesRepository + articles.repository.test.ts + articles.repository.mock.test.ts - interface-adapters/ # Clean Arch grouping (controllers now; presenters/gateways later) + interface-adapters/ # Clean Arch grouping (controllers now; presenters/gateways later) controllers/ - articles.controller.ts # Zod safeParse → InputParseError → use case - articles.controller.test.ts + get-articles.controller.ts # factory + safeParse(getArticlesInputSchema) + function presenter + get-articles.controller.test.ts # incl. R27/R28 if presenter reshapes + get-article-by-slug.controller.ts # one file per use case + get-article-by-slug.controller.test.ts + create-article.controller.ts + create-article.controller.test.ts - di/ # feature-local InversifyJS container - symbols.ts # BLOG_SYMBOLS - module.ts # ContainerModule - container.ts # blogContainer + getInjection() + di/ # feature-local InversifyJS container + symbols.ts # BLOG_SYMBOLS + module.ts # ContainerModule — .toDynamicValue() for use cases + controllers + container.ts # blogContainer singleton + bind-production.ts # swaps mock → real Payload impls at app boot container.test.ts - integrations/ # renamed from spec's adapters/ + integrations/ # renamed from spec's adapters/ + api/ + procedures.ts # blogProcedure = t.procedure.use(defineErrorMiddleware([...])) — Plan 9 + router.ts # blogProcedure.input(xInputSchema).query/mutation(...) + router.test.ts # incl. R26 router error-mapping test + index.ts cms/ collections/ - articles.ts # Payload CollectionConfig + articles.ts # Payload CollectionConfig hooks/ - after-post-change.ts # Payload lifecycle adapter → calls effects - index.ts # exports: articles (for core-cms composition) - api/ - router.ts # tRPC procedures → controllers - router.test.ts - - effects/ # only when needed - revalidate-post.ts - sync-post-search.ts - - jobs/ # only when needed - publish-scheduled-posts.ts - - events/ # only when needed - post-updated.ts + .ts # if needed + index.ts # exports: articles (for core-cms composition) ui/ - query.ts # trpc.blog.articleBySlug.queryOptions(...) - query.test.ts - article-client.tsx - article-client.test.tsx - page.tsx + index.ts # re-exports query builders (Plan 9 — apps import from @repo/blog/ui) + query.ts # trpc.blog.articleBySlug.queryOptions(...) - index.ts # re-exports ui components + public types + __factories__/ + article.factory.ts # test data factories (Plan 7) + + __contracts__/ + articles-repository.contract.ts # repo interface contract suite (Plan 7) + + index.ts # contracts only: types, errors, schemas, IUseCase/IController aliases, router type, constants tests/ - article-by-slug.feature.test.ts # cross-layer feature test + article-by-slug.feature.test.ts # cross-layer integration test - package.json # exports: ".", "./cms", "./api" + package.json # exports: ".", "./ui", "./api", "./cms", "./di/bind-production" tsconfig.json - turbo.json # tags: ["feature"] + turbo.json # tags: ["feature"] ``` Small-feature variant (e.g., `packages/navigation/`) omits folders without meaningful code per spec §15 / addendum v5 ("create folders only when needed"): @@ -228,33 +238,47 @@ Small-feature variant (e.g., `packages/navigation/`) omits folders without meani ``` packages/navigation/ src/ - entities/ nav.ts - infrastructure/repositories/ payload-navigation.repository.ts - di/ symbols.ts module.ts container.ts - interface-adapters/controllers/ navigation.controller.ts + entities/ + models/ header.ts + errors/ header.ts common.ts + application/repositories/ header.repository.interface.ts + infrastructure/repositories/ header.repository.ts header.repository.mock.ts + di/ symbols.ts module.ts container.ts bind-production.ts container.test.ts + interface-adapters/controllers/ get-header.controller.ts get-header.controller.test.ts integrations/ cms/ globals/ header.ts + index.ts - api/ router.ts - ui/ query.ts + api/ procedures.ts router.ts router.test.ts index.ts + ui/ index.ts query.ts index.ts ``` -No `application/use-cases/`, `effects/`, `jobs/`, `events/` unless the feature grows them. +No `effects/`, `jobs/`, `events/` unless the feature grows them. **Request flow:** ``` -useQuery(articleQuery(slug)) ui/query.ts (typed tRPC client) +useQuery(articleBySlugQuery({ slug })) ui/index.ts (typed tRPC client, via @repo/blog/ui) ↓ -tRPC router.articleBySlug integrations/api/router.ts - ↓ .input(zod).query(...) -articlesController.getBySlug(input) interface-adapters/controllers/ - ↓ safeParse → delegate -getArticleUseCase(slug) application/use-cases/ - ↓ getInjection(BLOG_SYMBOLS.IArticlesRepository) -PayloadArticlesRepository.getBySlug infrastructure/repositories/ (@injectable) - ↓ getPayload({ config }) from @repo/core-cms +tRPC router.articleBySlug integrations/api/router.ts + ↓ blogProcedure has defineErrorMiddleware applied + ↓ .input(getArticleBySlugInputSchema) +articlesController.getBySlug(input: unknown) interface-adapters/controllers/ + ↓ getArticleBySlugInputSchema.safeParse(input) + ↓ throws InputParseError on failure + ↓ delegates to use case +getArticleBySlugUseCase(parsed.data) application/use-cases/ + ↓ deps injected by container at xProcedure.use(...) time + ↓ throws ArticleNotFoundError on miss + ↓ ends with getArticleBySlugOutputSchema.parse(result) +ArticlesRepository.getArticleBySlug infrastructure/repositories/ + ↓ getPayload({ config }) from @repo/core-cms Payload Local API → PostgreSQL + ↑ on throw: + domain error → defineErrorMiddleware + → TRPCError(code, cause) + ↓ on success: + controller's `function presenter(value)` + shapes the view ``` **DI placement rationale:** `di/` sits at feature root (not under `infrastructure/`) because the container wires `application/` interfaces to `infrastructure/` implementations — it has knowledge of both layers and is a sibling to them, not a sub-layer. @@ -477,28 +501,49 @@ Tags govern architectural boundaries; `dependsOn: ["^build"]` governs task execu ### 10.3 DI in tests (per-feature container) -Each feature's tests import the feature's own container and rebind per test: +**Default (use case + controller tests) — direct factory injection.** Construct mock dependencies and pass them into the factory function. No container involvement: ```ts -import { blogContainer, BLOG_SYMBOLS } from '../../di/container' -import { MockArticlesRepository } from '../../infrastructure/repositories/mock-articles.repository' +// Use case test — direct factory injection (Plan 8 / ADR-012) +const repo = new MockArticlesRepository(); +const useCase = getArticleBySlugUseCase(repo); +const result = await useCase({ slug: "hello-world" }); +// Controller test — same pattern +const repo = new MockArticlesRepository(); +const useCase = getArticleBySlugUseCase(repo); +const controller = getArticleBySlugController(useCase); +const result = await controller({ slug: "hello-world" }); +``` + +**Router tests — container rebinding still appropriate.** tRPC routers resolve controllers via `container.get(SYMBOL)`, so router tests must rebind the container: + +```ts +// Router test (only here is container rebinding still appropriate) beforeEach(() => { - blogContainer.unbindAll() - blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).to(MockArticlesRepository) -}) + if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { + blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); + } + blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(new MockArticlesRepository()); +}); ``` No shared `initializeContainer()` / `destroyContainer()`. -### 10.4 Starter test coverage (end of refactor) +### 10.4 Actual test coverage (post-Plan-9) -- `core-shared`: 3 tests (slug-field, set-published-at, is-admin) -- `auth`: 6 tests (3 controllers + 3 use-cases) — replaces current auth unit tests -- `blog`: 3 tests (2 use-cases + 1 feature test for `articleBySlug`) -- `marketing-pages`, `navigation`, `media`: minimum one feature test each +After Plan 8 (Lazar conformance) and Plan 9 (I/O unification + presenter + error middleware): -Total ~15 unit/integration tests + 4 e2e = replaces current 9 tests with full new layout. +- **360 tests across 26 packages** (`pnpm test` green as of 2026-05-06) +- Plan 8 grew the suite from 244 → 325 tests (+81, +33%) — factory refactor + media scaffold +- Plan 9 grew the suite from 325 → 360 tests (+35, +11%) — R25 output-validation tests + R26 router error-mapping tests + R27/R28 presenter shape tests + +Key coverage areas added in these plans: +- R25 (output-validation): every non-void use case has a test asserting `xOutputSchema.parse` throws on malformed repository data +- R26 (router error-mapping): every feature has a router test asserting domain error → correct `TRPCError.code` translation +- R27/R28 (presenter shape): `auth` sign-in/sign-up controllers assert the presenter-reshaped view (cookie, not full session object) + +Cross-reference: Plan 8 refactor log Summary at `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` and Plan 9 refactor log Summary at `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`. ### 10.5 Playwright (included from day one) @@ -513,6 +558,21 @@ Total ~15 unit/integration tests + 4 e2e = replaces current 9 tests with full ne - ESLint config adds `eslint-plugin-playwright` for e2e folders - Playwright's `globalSetup` verifies Postgres is running; fails fast with a helpful message otherwise +### 10.6 Test obligations per layer (Plan 9) + +Every new use case and controller is expected to satisfy these rules. The rule IDs correspond to the spec `docs/superpowers/specs/2026-05-06-input-output-unification-design.md` §3. + +| Rule | Description | Layer | Where the test lives | +|---|---|---|---| +| R10 | Controller input must be typed `unknown`; schema is the gate | `interface-adapters/controllers/` | `*.controller.test.ts` — assert `InputParseError` on invalid input | +| R24 | Use-case + controller tests use direct factory injection; no `container.unbind/bind` | `application/use-cases/` + `interface-adapters/controllers/` | `*.use-case.test.ts`, `*.controller.test.ts` | +| R25 | Non-void use case has a test asserting `xOutputSchema.parse` throws on malformed repo data | `application/use-cases/` | `*.use-case.test.ts` | +| R26 | Every feature has a router test asserting domain error → expected `TRPCError.code` | `integrations/api/` | `router.test.ts` — call via `xRouter.createCaller({})` and assert `TRPCError.code` | +| R27 | When presenter strips/renames/transforms, controller test asserts the resulting view shape | `interface-adapters/controllers/` | `*.controller.test.ts` — assert omitted fields absent, transformed fields present | +| R28 | When controller has a non-identity presenter, tests assert the *view* shape (not `XOutput`) | `interface-adapters/controllers/` | `*.controller.test.ts` — catches regressions where presenter short-circuits to identity | + +Identity presenters do not require R27/R28 tests. Void-output controllers (e.g., `signOutController`, `deleteMediaController`) are exempt from R11 (presenter), R25, R27, and R28. + --- ## 11. Docs + ADR strategy @@ -558,6 +618,13 @@ All rewritten: Root `CLAUDE.md` — updated "Read First" pointers, unchanged port table, added boundary-enforcement note. +### 11.6 Post-spec ADRs (Plans 8 + 9) + +Two additional ADRs were added after the initial vertical-feature refactor and now form part of the permanent decision record: + +- `adr-012-lazar-conformance.md` — Plan 8: factory-function use cases + controllers, one-per-use-case controllers, Lazar file-layout conventions, real Payload implementations for `auth`, full `media` scaffold. Accepts the pattern with four intentional divergences (inversify retained, per-feature DI containers, colocated tests, no Sentry wrapping). +- `adr-013-input-output-unification.md` — Plan 9: use-case file is the single source of truth for `xInputSchema`/`xOutputSchema`; runtime output validation (`xOutputSchema.parse(result)`); co-located `function presenter` in every non-void controller; per-feature `procedures.ts` for domain error → `TRPCError` mapping via `defineErrorMiddleware` from `core-shared`; `./ui` subpath separates UI artifacts from contracts. + --- ## 12. Migration sequencing