c49532a3a7f1881c1c4365f2e98939bbf7220962 diff --git a/content/sama/v2-example-crud.md b/content/sama/v2-example-crud.md index 1feae1ae180e3eff8b6a2034d700c93d26658eda..08c418d0bef97e3d37c7125a67e5e235585bce47 100644 --- a/content/sama/v2-example-crud.md +++ b/content/sama/v2-example-crud.md @@ -250,6 +250,7 @@ Every column 2 entry is a deterministic check the verifier runs — the agent's ## See also - [/sama/v2](/sama/v2) — the canonical spec this profile instantiates +- [/sama/v2/example-wordpress](/sama/v2/example-wordpress) — the same shape against WordPress + PHP - [/sama/v2/verify](/sama/v2/verify) — the live verifier running against this site's own (different) profile - [/sama/v2#5-operational--core-metrics-definitions](/sama/v2#5-operational--core-metrics-definitions) — the §5 metrics this profile would emit diff --git a/content/sama/v2-example-wordpress.md b/content/sama/v2-example-wordpress.md new file mode 100644 index 0000000000000000000000000000000000000000..bc0d8436b4d4cdc77234efca8393eb757f96554b --- /dev/null +++ b/content/sama/v2-example-wordpress.md @@ -0,0 +1,313 @@ +# Worked example — a WordPress plugin under SAMA v2 + +This is the same shape as [/sama/v2/example-crud](/sama/v2/example-crud) but in PHP, against WordPress idioms — `add_action`/`add_filter` hooks, `$wpdb`, REST routes via `register_rest_route`, shortcodes, custom post types. The four canonical layers and the import law are unchanged; only the **profile** sublayer split changes to reflect what WordPress code actually does. + +The example domain is an **event-registration plugin** (`events-plugin`): visitors register for events, pay via Stripe, get a confirmation email, admins manage attendees from `/wp-admin`. + +> **Note on the verifier:** the live verifier at [/sama/v2/verify](/sama/v2/verify) currently scans TypeScript only (regex on `from "./...ts"` imports). The SAMA v2 spec is language-agnostic — the import law applies equally to PHP `use` statements and PSR-4 autoloaded classes — but a PHP-aware verifier is a separate piece of work. This page documents the **architectural shape**; the mechanical conformance check for a PHP repo would need its own implementation against the same §4 rules. + +## The profile + +```toml +sama_version = "2.0" +profile = "wordpress-plugin" + +# Layer 0 — Pure. Domain types, pricing math, validation rules. No WP +# globals ($wpdb, $post, $_POST), no calls to WP functions, no I/O, +# no clock. PHP itself only. +[layers.0] +prefixes = ["a_"] + +# Layer 1 — Core. Business decisions and orchestration. May not touch +# WP globals or call WP functions. Takes adapters at construction +# through the interfaces declared in a_ports.php. +# - b1_ policy: pure decisions (canRegister, isEligibleForRefund) +# - b2_ service: orchestrators that compose policy + delegate +[layers.1] +sublayers = [ + { name = "policy", prefix = "b1_" }, + { name = "service", prefix = "b2_" }, +] + +# Layer 2 — Adapter. The WordPress boundary. THIS is where $wpdb, +# wp_mail, get_option, $_POST, REST args, and HTTP-to-Stripe live. +# - c1_ repository: $wpdb queries (one per custom post type / table) +# - c2_ gateway: outbound HTTP (Stripe) + wp_mail() wrappers +# - c3_ controller: REST/admin/shortcode INPUT parsing — turns +# $_POST, $_GET, json_decode(WP_REST_Request body), and admin +# form posts into typed values +[layers.2] +sublayers = [ + { name = "repository", prefix = "c1_" }, + { name = "gateway", prefix = "c2_" }, + { name = "controller", prefix = "c3_" }, +] + +# Layer 3 — Entry. WordPress hook registrations + REST route +# registrations + shortcode callbacks + admin pages. Owns no business +# logic; every callback is parse-via-c3 → invoke-b2 → serialize. +[layers.3] +prefixes = ["d_"] +``` + +Lex check: `a_ < b1_ < b2_ < c1_ < c2_ < c3_ < d_`. Layer order: `0 < 1 < 1 < 2 < 2 < 2 < 3`. ✓ + +## The directory + +``` +events-plugin/ +├── events-plugin.php # WP-required root file (4 lines: header +│ # comment + require __DIR__.'/src/d_main.php') +├── src/ +│ ├── a_event.php # Layer 0 — Event, EventStatus types +│ ├── a_registration.php # Layer 0 — Registration, RegistrationStatus +│ ├── a_attendee.php # Layer 0 — Attendee, Email types +│ ├── a_pricing.php # Layer 0 — pure pricing + tax math +│ ├── a_ports.php # Layer 0 — interfaces: +│ │ # EventRepository, RegistrationRepository, +│ │ # PaymentGateway, EmailGateway, Clock +│ ├── b1_registration_policy.php # Layer 1 — canRegister, isEligibleForRefund +│ ├── b1_capacity_policy.php # Layer 1 — availableSeats, isWaitlistEligible +│ ├── b2_registration_service.php # Layer 1 — register, cancel, refund (compose) +│ ├── b2_payment_service.php # Layer 1 — chargeRegistration, refundRegistration +│ ├── c1_event_repo.php # Layer 2 — $wpdb-backed EventRepository +│ ├── c1_registration_repo.php # Layer 2 — $wpdb-backed RegistrationRepository +│ ├── c2_stripe_gateway.php # Layer 2 — outbound HTTP to Stripe via wp_remote_post +│ ├── c2_email_gateway.php # Layer 2 — wp_mail() wrapper +│ ├── c3_rest_controller.php # Layer 2 — parses WP_REST_Request bodies +│ ├── c3_admin_controller.php # Layer 2 — parses admin form $_POST +│ ├── c3_shortcode_controller.php # Layer 2 — parses shortcode atts +│ ├── d_main.php # Layer 3 — boots autoloader, wires deps, fires +│ │ # all add_action() registrations below +│ ├── d_rest_routes.php # Layer 3 — register_rest_route() calls +│ ├── d_admin_pages.php # Layer 3 — add_menu_page() + form handlers +│ ├── d_shortcodes.php # Layer 3 — add_shortcode() callbacks +│ └── d_cron_hooks.php # Layer 3 — wp_schedule_event() + cron callbacks + ++ sibling *.test.php for every b*_ and c*_ file (Modeled-tests §4.3) +``` + +The root-level `events-plugin.php` is WP's required header file — it has the plugin metadata WordPress scans for, and a single `require` line. Everything else lives under `src/` and follows the prefix scheme. + +## Layer 0 — Pure + +Domain types as plain PHP classes, validation rules as data, pure transforms. **No WP function calls whatsoever**: no `$wpdb`, no `$_POST`, no `get_option`, no `wp_mail`, no `current_time`, no `$post`. PHP standard library only. + +```php +// a_event.php +final class Event { + public function __construct( + public readonly string $id, + public readonly string $title, + public readonly string $startsAtIso, // ISO 8601 string; produced by Layer 1+ + public readonly int $capacity, + public readonly int $priceCents, + ) {} +} + +// a_ports.php — the interfaces Layer 1 calls THROUGH. Concrete +// implementations live in Layer 2; declaring the interface here lets +// b2_registration_service.php import only the type, never the c1_ class. +interface EventRepository { + public function findById(string $id): ?Event; + public function save(Event $event): void; + public function listUpcoming(string $nowIso): array; +} + +interface PaymentGateway { + public function charge(string $registrationId, int $amountCents): PaymentResult; +} + +interface Clock { + public function nowIso(): string; +} +``` + +The Clock port matters because `time()`, `current_time()`, `new DateTime()` are all forms of I/O the spec keeps out of Layer 0/1. Higher layers inject a `Clock` instance whose Layer 2 implementation calls WP's `current_time('mysql')`. Tests construct a `FixedClock` that returns whatever ISO string the test wants. + +## Layer 1 — Core + +Business logic. Two sublayers — `b1_` policy (pure functions over domain state) and `b2_` service (orchestrators that compose policy + call through ports). + +```php +// b1_capacity_policy.php — pure +final class CapacityPolicy { + public static function availableSeats(Event $event, int $confirmedCount): int { + return max(0, $event->capacity - $confirmedCount); + } + + public static function isWaitlistEligible(Event $event, int $confirmedCount): bool { + return $confirmedCount >= $event->capacity + && $confirmedCount < $event->capacity + 10; // 10-seat waitlist + } +} + +// b2_registration_service.php — orchestrator +final class RegistrationService { + public function __construct( + private EventRepository $events, + private RegistrationRepository $registrations, + private PaymentGateway $payments, + private EmailGateway $emails, + private Clock $clock, + ) {} + + public function register(RegisterInput $input): Registration { + $event = $this->events->findById($input->eventId) + ?? throw new \RuntimeException('event not found'); + $confirmed = $this->registrations->countConfirmed($event->id); + if (CapacityPolicy::availableSeats($event, $confirmed) <= 0) { + throw new \RuntimeException('event sold out'); + } + $registration = new Registration(/* ... */); + $charge = $this->payments->charge($registration->id, $event->priceCents); + // ... persist + email confirmation ... + return $registration; + } +} +``` + +Notice what `RegistrationService` does **not** import: any `c1_`/`c2_`/`c3_`/`d_` file. It takes ports at construction. The DI happens in Layer 3. + +## Layer 2 — Adapter + +The WordPress boundary. Three sublayers: + +```php +// c1_event_repo.php — implements a_ports.EventRepository against $wpdb +final class WpdbEventRepository implements EventRepository { + public function __construct(private \wpdb $db) {} + + public function findById(string $id): ?Event { + $row = $this->db->get_row( + $this->db->prepare("SELECT * FROM {$this->db->prefix}events WHERE id = %s", $id) + ); + return $row ? $this->rowToEvent($row) : null; + } + + public function save(Event $event): void { + $this->db->replace("{$this->db->prefix}events", [ + 'id' => $event->id, + 'title' => $event->title, + 'starts_at' => $event->startsAtIso, + 'capacity' => $event->capacity, + 'price_cents' => $event->priceCents, + ]); + } +} + +// c2_stripe_gateway.php — outbound HTTP via wp_remote_post +final class StripePaymentGateway implements PaymentGateway { + public function __construct(private string $apiKey) {} + + public function charge(string $registrationId, int $amountCents): PaymentResult { + $res = wp_remote_post('https://api.stripe.com/v1/charges', [ + 'headers' => ['Authorization' => "Bearer {$this->apiKey}"], + 'body' => ['amount' => $amountCents, 'currency' => 'eur', 'metadata[registration_id]' => $registrationId], + ]); + // ... parse $res, return PaymentResult ... + } +} + +// c3_rest_controller.php — parses incoming WP_REST_Request bodies. +// THIS is the only sublayer allowed to call json_decode on external +// input, read $_POST, or inspect raw request bodies. +final class RegistrationRestController { + public function parseRegisterRequest(\WP_REST_Request $req): RegisterInputOrError { + $body = $req->get_json_params(); // WP's parser; the boundary is here + if (!is_array($body)) return RegisterInputOrError::error('invalid JSON'); + if (empty($body['event_id']) || !is_string($body['event_id'])) { + return RegisterInputOrError::error('missing event_id'); + } + // ... validate every field, return typed RegisterInput ... + } +} +``` + +Within Layer 2, controllers may import gateways + repositories (controller is the outermost sublayer in the adapter layer). Repositories may not import controllers — they don't know they're serving an HTTP request. + +## Layer 3 — Entry + +WordPress hook registrations, REST routes, shortcodes, admin pages. **Owns zero business logic.** Every callback is the same three-step composition: parse via `c3_*` → call `b2_*_service` → return. + +```php +// d_main.php — bootstraps the plugin. Constructs concrete adapters, +// wires them into services, exposes the services to the hook files. +global $events_plugin_services; + +$events_plugin_services = (object)[ + 'events' => new WpdbEventRepository($GLOBALS['wpdb']), + 'registrations' => new WpdbRegistrationRepository($GLOBALS['wpdb']), + 'payments' => new StripePaymentGateway(get_option('events_plugin_stripe_key')), + 'emails' => new WpMailEmailGateway(), + 'clock' => new WpClock(), +]; + +require __DIR__ . '/d_rest_routes.php'; +require __DIR__ . '/d_admin_pages.php'; +require __DIR__ . '/d_shortcodes.php'; +require __DIR__ . '/d_cron_hooks.php'; + +// d_rest_routes.php +add_action('rest_api_init', function () { + global $events_plugin_services; + register_rest_route('events-plugin/v1', '/register', [ + 'methods' => 'POST', + 'callback' => function (\WP_REST_Request $req) { + $controller = new RegistrationRestController(); + $parsed = $controller->parseRegisterRequest($req); // c3 + if ($parsed->isError()) return new \WP_Error('bad_request', $parsed->error(), ['status' => 400]); + $service = new RegistrationService( + $GLOBALS['events_plugin_services']->events, + $GLOBALS['events_plugin_services']->registrations, + $GLOBALS['events_plugin_services']->payments, + $GLOBALS['events_plugin_services']->emails, + $GLOBALS['events_plugin_services']->clock, + ); + $registration = $service->register($parsed->value()); // b2 + return rest_ensure_response(['id' => $registration->id]); + }, + 'permission_callback' => '__return_true', + ]); +}); + +// d_shortcodes.php +add_shortcode('event_register_button', function ($atts) { + global $events_plugin_services; + $atts = (new RegistrationShortcodeController())->parseAtts($atts); // c3 + $event = $events_plugin_services->events->findById($atts->eventId); // direct c1 OK (read path) + return render_register_button($event); +}); +``` + +Hook registrations live in Layer 3 because they're the *wiring* — they tell WordPress "when X happens, call this callback." The callback body composes c3 + b2; it does not implement business rules inline. + +## Common mistakes — and the check that catches them + +| What a WordPress dev might do | Which v2 check catches it | +|---|---| +| Call `$wpdb->get_results(...)` inside `b2_registration_service.php` | **#6 Law (§1.2)** — Layer 1 → Layer 2 is upward (use the `EventRepository` port instead) | +| Use `$_POST['event_id']` inside `b1_registration_policy.php` | **#6 Law** + **#4 Modeled-boundary** — superglobals are Layer 2 boundary input, accessible only in `c3_*` controllers | +| Put `wp_mail(...)` directly inside a `d_*` hook callback (skipping the `c2_email_gateway`) | **Not a v2 violation** — Layer 3 may use Layer 2 directly. But it's a *design smell*: business outcomes (sending a confirmation) belong in `b2_*_service`, which the hook should call. The verifier won't catch this; a code reviewer might. | +| Register `add_action('init', ...)` inside `b2_registration_service.php` | **#6 Law** — `add_action` is a Layer 2 framework binding (WP function call); Layer 1 must not depend on it. Hooks live in `d_*` only. | +| Call `current_time('mysql')` in `a_pricing.php` | **#6 Law** + **#7 Consistency** — `a_` claims Pure but the file imports a WP function (Layer 2). Use a `Clock` port instead. | +| Add a top-level `src/helpers.php` with no prefix | **#2 Architecture** — unprefixed file maps to no canonical layer | +| Let `c1_registration_repo.php` grow to 800 LOC of SQL | **#5 Atomic** — over the 700-line cap; split (e.g. into `c1_registration_repo.php` + `c1_registration_repo_queries.php`) | +| `json_decode($req->get_body())` inside a `d_*` REST callback | **#4 Modeled-boundary** — JSON parsing is a Layer 2 controller concern; move it into `c3_rest_controller.php` | +| Same-layer reverse: `b1_*` policy importing a `b2_*` service | **#6 Law (§2.2)** — `b2` is later in the sublayer order; `b1` may not import `b2` | + +## WordPress idioms that fit cleanly under v2 + +- **Custom Post Types** — register them in Layer 3 (`d_main.php` calls `register_post_type` during the `init` hook). Their schemas (allowed statuses, taxonomy structure) live in Layer 0 as plain data. +- **The `$wpdb` global** — only `c1_*` repositories may touch it. Pass `$GLOBALS['wpdb']` into the repository constructor in `d_main.php`; never reach for the global from anywhere else. +- **Hook priorities & filter chains** — they live in `d_*` only. If your `b2_*_service.php` needs configurable behavior, that's a port (interface in `a_ports.php`) passed at construction, not an `apply_filters()` call from inside business logic. +- **REST permission callbacks** — small enough to inline in `d_rest_routes.php`. Anything beyond an auth check (e.g. "may this user cancel this registration?") becomes a `b1_*` policy function. +- **Transients / object cache** — Layer 2 adapter. The cache is I/O, even if it looks like a hash table. + +## See also + +- [/sama/v2](/sama/v2) — the canonical spec this profile instantiates +- [/sama/v2/example-crud](/sama/v2/example-crud) — the same shape for a Bun/TypeScript HTTP service +- [/sama/v2/verify](/sama/v2/verify) — the live TS-only verifier (PHP support is a separate piece of work) + +[← /sama/v2](/sama/v2) · [← /sama](/sama) diff --git a/content/sama/v2.md b/content/sama/v2.md index 5b1072366464d1a645c996395dc353443802619f..c190c107c283c7b44e143db439821267bad60fff 100644 --- a/content/sama/v2.md +++ b/content/sama/v2.md @@ -97,7 +97,7 @@ prefixes = ["e3_"] A `cli` profile would leave `[layers.2]` minimal and subdivide `[layers.3]` into `arg-parser → dispatch`. A `frontend` profile would subdivide Layer 1 into `store` vs `view-logic`. Same law, different dialect. -→ **Worked example:** [a CRUD HTTP service under v2](/sama/v2/example-crud) — full profile, directory tree, per-layer code signatures, and the common mistakes each §4 check catches. +→ **Worked examples:** [a CRUD HTTP service under v2](/sama/v2/example-crud) (TypeScript) · [a WordPress plugin under v2](/sama/v2/example-wordpress) (PHP) — both ship full profiles, directory trees, per-layer code signatures, and the common mistakes each §4 check catches. --- diff --git a/src/d21_app.ts b/src/d21_app.ts index d693684d7bac6f7e7289aef02a9ec7fc7b307896..86ab4ebf455f98939ec8b599822b8fb5d96cd2e3 100644 --- a/src/d21_app.ts +++ b/src/d21_app.ts @@ -34,6 +34,7 @@ import { samaSkillHandler, samaV2Handler, samaV2ExampleCrudHandler, + samaV2ExampleWordpressHandler, samaV2VerifyHandler, samaVerifyHandler, samaLandingHandler, @@ -368,6 +369,8 @@ ${rows} "/sama/v2/example-crud": samaV2ExampleCrudHandler, + "/sama/v2/example-wordpress": samaV2ExampleWordpressHandler, + "/sama/verify": samaVerifyHandler, "/sama": samaLandingHandler, diff --git a/src/d21_handlers_sama.ts b/src/d21_handlers_sama.ts index 0238e692c59a631f0744bb491121ada4f1213df6..265019fb17e21db05a9b2fd949edf9d118f4ea23 100644 --- a/src/d21_handlers_sama.ts +++ b/src/d21_handlers_sama.ts @@ -201,6 +201,22 @@ export const samaV2ExampleCrudHandler = async (): Promise => { return htmlResponse(html); }; +// -------- /sama/v2/example-wordpress (worked-example profile for a WP plugin) -------- + +export const samaV2ExampleWordpressHandler = async (): Promise => { + const md = await Bun.file("./content/sama/v2-example-wordpress.md").text(); + const html = await renderDocsPage({ + title: "SAMA v2 — worked example: WordPress plugin — tdd.md", + description: + "The SAMA v2 spec applied to a WordPress plugin: $wpdb in Layer 2 only, hooks in Layer 3, no WP function calls in Layer 0/1. Profile, directory layout, per-layer PHP signatures, and the common WordPress mistakes each §4 check catches.", + bodyMarkdown: md, + ogPath: "https://tdd.md/sama/v2/example-wordpress", + active: "sama", + pathForDocs: "/sama/v2/example-wordpress", + }); + return htmlResponse(html); +}; + // -------- /sama/verify (form + report + dogfood short-circuit) -------- const VERIFY_FORM_MD = `# SAMA verify