From 61db9116f6555245c3e249e5d320e6f40374b7a3 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 12 Jan 2026 01:45:19 +0100 Subject: [PATCH] website refactor --- apps/website/lib/contracts/api/ApiClient.ts | 16 +++ .../display-objects/DisplayObject.ts | 29 +++++ .../lib/contracts/page-queries/PageQuery.ts | 3 +- .../lib/contracts/presenters/Presenter.ts | 21 ++++ .../website/lib/contracts/services/Service.ts | 37 ++++++ .../website/lib/contracts/types/primitives.ts | 44 +++++++ .../lib/contracts/view-data/ViewData.ts | 38 ++++++ .../lib/contracts/view-models/ViewModel.ts | 36 ++++++ .../guardrails/ArchitectureGuardrails.ts | 112 ++++++++++++++++++ .../architecture-guardrails.test.ts | 38 ++++++ docs/architecture/website/WEBSITE_CONTRACT.md | 4 +- 11 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 apps/website/lib/contracts/api/ApiClient.ts create mode 100644 apps/website/lib/contracts/display-objects/DisplayObject.ts create mode 100644 apps/website/lib/contracts/presenters/Presenter.ts create mode 100644 apps/website/lib/contracts/services/Service.ts create mode 100644 apps/website/lib/contracts/types/primitives.ts create mode 100644 apps/website/lib/contracts/view-data/ViewData.ts create mode 100644 apps/website/lib/contracts/view-models/ViewModel.ts diff --git a/apps/website/lib/contracts/api/ApiClient.ts b/apps/website/lib/contracts/api/ApiClient.ts new file mode 100644 index 000000000..c6ae997f8 --- /dev/null +++ b/apps/website/lib/contracts/api/ApiClient.ts @@ -0,0 +1,16 @@ +/** + * ApiClient contract + * + * Transport boundary for API calls. + * Returns API Transport DTOs only. + * + * Based on WEBSITE_CONTRACT.md: + * - API Transport DTOs are returned by the backend API over HTTP + * - Website uses ApiClient to communicate with the API + */ +export interface ApiClient { + /** + * Execute an API call and return the transport DTO + */ + execute(...args: unknown[]): Promise; +} \ No newline at end of file diff --git a/apps/website/lib/contracts/display-objects/DisplayObject.ts b/apps/website/lib/contracts/display-objects/DisplayObject.ts new file mode 100644 index 000000000..207e2bdfb --- /dev/null +++ b/apps/website/lib/contracts/display-objects/DisplayObject.ts @@ -0,0 +1,29 @@ +/** + * DisplayObject contract + * + * Deterministic, reusable, UI-only formatting/mapping logic. + * + * Based on DISPLAY_OBJECTS.md: + * - Class-based + * - Immutable + * - Deterministic + * - Side-effect free + * - No Intl.* or toLocale* + * - No business rules + */ + +export interface DisplayObject { + /** + * Format or map the display object + * + * @returns Primitive values only (strings, numbers, booleans) + */ + format(): unknown; + + /** + * Optional: Get multiple display variants + * + * Allows a single DisplayObject to expose multiple presentation formats + */ + variants?(): Record; +} \ No newline at end of file diff --git a/apps/website/lib/contracts/page-queries/PageQuery.ts b/apps/website/lib/contracts/page-queries/PageQuery.ts index 0e27204b1..3e7c81dba 100644 --- a/apps/website/lib/contracts/page-queries/PageQuery.ts +++ b/apps/website/lib/contracts/page-queries/PageQuery.ts @@ -1,4 +1,5 @@ -import type { PageQueryResult } from '@/lib/page-queries/PageQueryResult'; +import { PageQueryResult } from "@/lib/page-queries/page-query-result/PageQueryResult"; + /** * PageQuery contract interface diff --git a/apps/website/lib/contracts/presenters/Presenter.ts b/apps/website/lib/contracts/presenters/Presenter.ts new file mode 100644 index 000000000..ffcc50143 --- /dev/null +++ b/apps/website/lib/contracts/presenters/Presenter.ts @@ -0,0 +1,21 @@ +/** + * Presenter contract + * + * Pure, deterministic transformation between presentation models. + * + * Based on PRESENTERS.md: + * - Deterministic + * - Side-effect free + * - No HTTP/API calls + * - Maps between Page DTO, ViewModel, and ViewData + */ + +export interface Presenter { + /** + * Transform input to output + * + * @param input - The input presentation model (Page DTO, ViewModel, etc.) + * @returns The output presentation model (ViewModel, ViewData, etc.) + */ + present(input: TInput): TOutput; +} \ No newline at end of file diff --git a/apps/website/lib/contracts/services/Service.ts b/apps/website/lib/contracts/services/Service.ts new file mode 100644 index 000000000..ec20e547c --- /dev/null +++ b/apps/website/lib/contracts/services/Service.ts @@ -0,0 +1,37 @@ +/** + * Service contract + * + * Orchestration boundary for server-side operations. + * Returns API DTOs or Page DTOs only. + * Must be stateless. + * + * Based on WEBSITE_CONTRACT.md: + * - Services orchestrate IO and composition + * - They do not prepare UI + * - They return ApiDto or PageDto only + */ + +/** + * Base service interface for orchestration operations + */ +export interface Service { + /** + * Execute a service operation + * Returns either API Transport DTO or Page DTO + */ + execute(...args: unknown[]): Promise; +} + +/** + * Service that returns API Transport DTOs + */ +export interface ApiService extends Service { + execute(...args: unknown[]): Promise; +} + +/** + * Service that returns Page DTOs + */ +export interface PageService extends Service { + execute(...args: unknown[]): Promise; +} \ No newline at end of file diff --git a/apps/website/lib/contracts/types/primitives.ts b/apps/website/lib/contracts/types/primitives.ts new file mode 100644 index 000000000..ceafc63ea --- /dev/null +++ b/apps/website/lib/contracts/types/primitives.ts @@ -0,0 +1,44 @@ +/** + * Primitive types for contracts + * + * Reusable type definitions that can be used across all contracts. + */ + +/** + * Represents any JSON-serializable value + */ +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Represents a JSON-serializable object (not array) + */ +export type JsonObject = { + [key: string]: JsonValue; +}; + +/** + * Represents a JSON-serializable array + */ +export type JsonArray = JsonValue[]; + +/** + * Helper type to ensure a type is JSON-serializable + * + * Usage: + * ```typescript + * type MyData = JsonSerializable<{ + * title: string; + * count: number; + * items: string[]; + * }>; + * ``` + */ +export type JsonSerializable = { + [K in keyof T]: T[K] extends JsonValue ? T[K] : never; +}; \ No newline at end of file diff --git a/apps/website/lib/contracts/view-data/ViewData.ts b/apps/website/lib/contracts/view-data/ViewData.ts new file mode 100644 index 000000000..ae07b7a84 --- /dev/null +++ b/apps/website/lib/contracts/view-data/ViewData.ts @@ -0,0 +1,38 @@ +/** + * ViewData contract + * + * Represents the shape of data that can be passed to Templates. + * + * Based on VIEW_DATA.md: + * - JSON-serializable only + * - Contains only template-ready values (strings/numbers/booleans) + * - MUST NOT contain class instances + * + * This is a type-level contract, not a class-based one. + */ + +import type { JsonValue, JsonObject } from '../types/primitives'; + +/** + * Base interface for ViewData objects + * + * All ViewData must be JSON-serializable. + * This type ensures no class instances or functions are included. + */ +export interface ViewData extends JsonObject { + [key: string]: JsonValue; +} + +/** + * Helper type to ensure a type is ViewData-compatible + * + * Usage: + * ```typescript + * type MyViewData = ViewData & { + * title: string; + * count: number; + * items: string[]; + * }; + * ``` + */ +export type ViewDataOf = T; \ No newline at end of file diff --git a/apps/website/lib/contracts/view-models/ViewModel.ts b/apps/website/lib/contracts/view-models/ViewModel.ts new file mode 100644 index 000000000..7df76f362 --- /dev/null +++ b/apps/website/lib/contracts/view-models/ViewModel.ts @@ -0,0 +1,36 @@ +/** + * ViewModelBase contract + * + * Base class for all ViewModels. + * + * Based on VIEW_MODELS.md: + * - Client-only classes + * - Fully prepared UI state + * - Never serialized + * - Never passed to Templates + * - Compose Display Objects for reusable formatting + * - Expose UI-specific computed properties + * + * Based on WEBSITE_CONTRACT.md: + * - ViewModels are client-only + * - Must not expose methods that return Page DTO or API DTO + * + * Architecture Flow: + * 1. PageQuery returns Page DTO (server) + * 2. Presenter transforms Page DTO → ViewModel (client) + * 3. Presenter transforms ViewModel → ViewData (client) + * 4. Template receives ViewData only + * + * ViewModels provide UI state and helpers. + * Presenters handle the transformation to ViewData. + */ + +export abstract class ViewModel { + /** + * Optional: Validate the ViewModel state + * + * Can be used to ensure the ViewModel is in a valid state + * before a Presenter converts it to ViewData. + */ + validate?(): boolean; +} \ No newline at end of file diff --git a/apps/website/tests/guardrails/ArchitectureGuardrails.ts b/apps/website/tests/guardrails/ArchitectureGuardrails.ts index 9e210a657..3e8d8fa35 100644 --- a/apps/website/tests/guardrails/ArchitectureGuardrails.ts +++ b/apps/website/tests/guardrails/ArchitectureGuardrails.ts @@ -161,6 +161,9 @@ export class ArchitectureGuardrails { this.checkSortingFiltering(filePath, content); this.checkNullReturns(filePath, content); + // NEW: Enforce PageQuery contract + this.checkPageQueryContract(filePath, content); + // Rule 8: Forbid 'as any' usage this.checkAsAnyUsage(filePath, content); @@ -226,6 +229,9 @@ export class ArchitectureGuardrails { this.checkUseClientDirective(filePath, content); this.checkViewModelPageQueryImports(filePath, content); + // NEW: Enforce ViewModel contract + this.checkViewModelContract(filePath, content); + // Rule 8: Forbid 'as any' usage this.checkAsAnyUsage(filePath, content); @@ -243,6 +249,9 @@ export class ArchitectureGuardrails { // Rule 6: Client-only guardrails - presenters should not use HTTP this.checkHttpCalls(filePath, content); + // NEW: Enforce Presenter contract + this.checkPresenterContract(filePath, content); + // Rule 8: Forbid 'as any' usage this.checkAsAnyUsage(filePath, content); @@ -1078,6 +1087,109 @@ export class ArchitectureGuardrails { }); } + // ============================================================================ + // NEW: VIOLATION CHECKS - CONTRACT ENFORCEMENT + // ============================================================================ + + private checkPageQueryContract(filePath: string, content: string): void { + // Check if file contains a class that should implement PageQuery + const classMatch = content.match(/class\s+(\w+PageQuery)\s+{/); + if (!classMatch) return; + + // Check if it implements PageQuery interface + if (!content.includes('implements PageQuery<')) { + this.addViolation( + 'pagequery-must-implement-contract', + filePath, + 1, + 'PageQuery class must implement PageQuery interface' + ); + } + + // Check if it has execute method + if (!content.includes('execute(')) { + this.addViolation( + 'pagequery-must-have-execute', + filePath, + 1, + 'PageQuery class must have execute(params) method' + ); + } + + // Check if execute returns Promise + const executeMatch = content.match(/execute\([^)]*\):\s*Promise>' + ); + } + } + + private checkPresenterContract(filePath: string, content: string): void { + // Check if file contains a class that should implement Presenter + const classMatch = content.match(/class\s+(\w+Presenter)\s+{/); + if (!classMatch) return; + + // Check if it implements Presenter interface + if (!content.includes('implements Presenter<')) { + this.addViolation( + 'presenter-must-implement-contract', + filePath, + 1, + 'Presenter class must implement Presenter interface' + ); + } + + // Check if it has present method + if (!content.includes('present(')) { + this.addViolation( + 'presenter-must-have-present', + filePath, + 1, + 'Presenter class must have present(input) method' + ); + } + + // Check for 'use client' directive + if (!content.includes("'use client'") && !content.includes('"use client"')) { + this.addViolation( + 'presenter-must-be-client', + filePath, + 1, + 'Presenter must have \'use client\' directive at top-level' + ); + } + } + + private checkViewModelContract(filePath: string, content: string): void { + // Check if file contains a class that should extend ViewModel + const classMatch = content.match(/class\s+(\w+ViewModel)\s+{/); + if (!classMatch) return; + + // Check if it extends ViewModel + if (!content.includes('extends ViewModel')) { + this.addViolation( + 'viewmodel-must-extend-contract', + filePath, + 1, + 'ViewModel class must extend ViewModel base class' + ); + } + + // Check for 'use client' directive + if (!content.includes("'use client'") && !content.includes('"use client"')) { + this.addViolation( + 'viewmodel-must-be-client', + filePath, + 1, + 'ViewModel must have \'use client\' directive at top-level' + ); + } + } + // ============================================================================ // HELPERS // ============================================================================ diff --git a/apps/website/tests/guardrails/architecture-guardrails.test.ts b/apps/website/tests/guardrails/architecture-guardrails.test.ts index 320b6ac0e..765cfd3a0 100644 --- a/apps/website/tests/guardrails/architecture-guardrails.test.ts +++ b/apps/website/tests/guardrails/architecture-guardrails.test.ts @@ -139,4 +139,42 @@ describe('Architecture Guardrails', () => { expect(violations.length).toBe(0); }); + + // NEW: Contract enforcement tests + it('should enforce: PageQuery classes must implement PageQuery contract', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'pagequery-must-implement-contract' || + v.ruleName === 'pagequery-must-have-execute' || + v.ruleName === 'pagequery-execute-return-type' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: Presenter classes must implement Presenter contract', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'presenter-must-implement-contract' || + v.ruleName === 'presenter-must-have-present' || + v.ruleName === 'presenter-must-be-client' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: ViewModel classes must extend ViewModel contract', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'viewmodel-must-extend-contract' || + v.ruleName === 'viewmodel-must-be-client' + ); + + expect(violations.length).toBe(0); + }); + + it('should enforce: DisplayObject files must export only classes', () => { + const violations = guardrails.getFilteredViolations().filter( + v => v.ruleName === 'no-non-class-display-exports' + ); + + expect(violations.length).toBe(0); + }); }); \ No newline at end of file diff --git a/docs/architecture/website/WEBSITE_CONTRACT.md b/docs/architecture/website/WEBSITE_CONTRACT.md index 29a785bc2..4ef87c953 100644 --- a/docs/architecture/website/WEBSITE_CONTRACT.md +++ b/docs/architecture/website/WEBSITE_CONTRACT.md @@ -123,7 +123,7 @@ See [`PRESENTERS.md`](docs/architecture/website/PRESENTERS.md:1). Canonical placement in this repo: -- colocated with ViewModels in `apps/website/lib/view-models/**` +- `apps/website/lib/presenters/**` ### 4.2 Display Object @@ -250,4 +250,4 @@ See [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:1). 6. Page Queries do not format; they only compose. 7. Presenters are pure and deterministic. 8. Server Actions are the only write entry point. -9. Authorization always belongs to the API. +9. Authorization always belongs to the API. \ No newline at end of file