v0.2.0 (5/N): close audit sweep — BridgeOp contract test + PLAN.md status

The audit's substantive items shipped in chunks 1–4. Two remaining
loose ends inspected and parked:

- Generated controller findOr404 boilerplate. MapEntity changes the
  404 response shape away from problem+json unless framework-level
  RFC 7807 error config is updated; a private helper is net-zero
  on lines. Parking until either (a) skeleton-level RFC 7807 error
  wiring, or (b) --with-dto flipping to default-on and the legacy
  template's polish becoming irrelevant.
- ModelPublisher::extractId reflection branch. Looks dead because
  every maker-output entity has getId(), but it remains a safety net
  for hand-written entities that don't. Keeping.

This commit ships:

- BridgeOpTest — locks the enum case values against accidental
  rename. Every case value is a documented wire-format token QML
  clients hardcode, so renaming a `value` is a wire-protocol break
  and this fails the build before it ships.

- PLAN.md §13 v0.2.0 status block with what's shipped on dev
  (interfaces / BridgeOp / BridgeBundleInfo / Maker DRY / --with-dto)
  and what's still open (findOr404 polish, --with-dto default flip).

Test count 23 → 24, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:15:16 +02:00
parent 5498c3c91e
commit f2d931e0a5
3 changed files with 41 additions and 8 deletions

View File

@@ -27,6 +27,10 @@ This section tracks work landing on `dev` toward **v0.2.0** (next minor; pre-1.0
- **`ModelPublisher::extractId` reflection cleanup.** Removed the `setAccessible(true)` call (deprecated since PHP 8.1; all properties are accessible via Reflection without it).
### Tests
- **`BridgeOpTest` wire-format contract.** Locks the four enum case values (`upsert` / `delete` / `replace` / `event`) against accidental rename — QML clients hardcode the strings, so a `value` change is a wire-protocol break and the test fails the build before it ships.
## [0.1.2] — 2026-05-03
Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the v0.1.2 cycle (bundled-mode supervisor cleanly SIGTERMs its child on host exit) with three non-breaking fixes from a post-v0.1.1 architecture audit.

17
PLAN.md
View File

@@ -554,17 +554,18 @@ Bugfix release. Bundles the v0.1.1 follow-up that surfaced during the cycle (cle
Pulls in the originally-Phase-3/5-deferred items that don't need new operational dependencies, plus the smaller §12 risks **and** the public-API / DX items surfaced by the post-v0.1.2 audit. Cross-platform packaging is parked at v0.9.0 — the operational lift (self-hosted runners + platform certs) is too big to fold into the next minor and Linux remains the primary target until the framework's surface stabilises.
**Public-API surface (audit-driven, breaks pre-1.0 SemVer permitted):**
**Shipped on `dev` toward v0.2.0** (audit-driven cleanup batch — see CHANGELOG `[Unreleased]` for full notes):
- **Ship interfaces for the bridge's three public services.** `Publisher`, `ModelPublisher`, and `CorrelationContext` are typehinted concretely everywhere (the Doctrine listener, the example `PingController`, every user controller that wants to fire a manual envelope) — the matching upstream Symfony idiom is `HubInterface` / `EventDispatcherInterface` / `NormalizerInterface`. Extract `PublisherInterface`, `ModelPublisherInterface`, `CorrelationContextInterface`; have the concrete classes implement them; switch every internal typehint over; document the interfaces as the public contract. Lets app code mock at the seam without a concrete-class spy and lets us iterate the implementations behind the contract.
- **`BridgeOp` enum.** `'upsert'` / `'delete'` are passed as raw strings between `DoctrineBridgeListener` and `ModelPublisher::publishEntityChange`. PHP 8.1 backed enum is the obvious typed replacement; PLAN.md §4's envelope `op` field already enumerates `upsert` | `delete` | `replace` | `event` so the enum encodes a documented contract. Method signature change is API-visible — pre-1.0 SemVer permits it; ship deprecation paths if the audit surfaces external callers.
- **`HealthController` deep-load canary refactor.** Constructor-injects `Publisher` only as a "is the bundle resolvable" probe (added in v0.1.1). Switching the dependency to a tiny `BridgeBundleInfo` value object that the bundle registers documents intent and decouples `/healthz` from the publisher contract — important once `PublisherInterface` lands.
- ✅ `PublisherInterface` / `ModelPublisherInterface` / `CorrelationContextInterface`. Concrete classes implement them; internal typehints switched over.
- `BridgeOp` enum (`Upsert` / `Delete` / `Replace` / `Event`). `ModelPublisher::publishEntityChange()` now takes `BridgeOp` instead of `string` (pre-1.0 SemVer break).
- ✅ `BridgeBundleInfo` VO. `HealthController` constructor-injects this instead of `PublisherInterface` as the deep-load canary; `/healthz` `bundle` field reports `PhpQml\Bridge\BridgeBundle`, plus a new `name` field (`php-qml/bridge`).
- ✅ `Maker\Support\NameInput::askOrFail()` + `Maker\Support\Naming::camelTo()`. The duplicated name-prompt closure and camel-conversion regex collapsed into single call sites.
- ✅ `make:bridge:resource --with-dto` opt-in. Generates `Create<Name>Dto` + `Update<Name>Dto` and rewrites controller actions to `#[MapRequestPayload]` dispatch — closes the input-validation gap (malformed JSON → 400 problem+json automatically; no more `if (isset($data['title']))` boilerplate). Skeleton + example/todo composer.json pull `symfony/validator`. Snapshot test covers both modes.
**Maker DRY + DX (audit-driven):**
**Still open for v0.2.0** (PLAN.md notes preserved below):
- **Maker shared helpers.** All three makers re-implement the same name-prompt-or-fail closure (`ucfirst(trim(…))` plus throw on empty) and re-spell their own camel-to-snake / camel-to-kebab regexes inline. Extract `Maker\Support\NameInput::askOrFail()` and `Maker\Support\Naming::camelTo($name, '_'|'-')` — single source of truth, three call sites.
- **DTO-shaped controller scaffold (`make:bridge:resource --with-dto`).** Generated CRUD controllers currently accept any JSON shape: `if (isset($data['title'])) …` with silent type coercion, no required-field enforcement, malformed JSON swallowed as `?? []`. Add a `--with-dto` option that emits `Create<Name>Dto` + `Update<Name>Dto` DTOs alongside the controller and rewrites the action signatures to `#[MapRequestPayload] CreateTodoDto $dto`. Pulls `symfony/validator` into the skeleton/example dependencies; `#[Assert\NotBlank]` on title fields is the headline default. Symfony's payload-mapping infrastructure produces RFC 7807 problem+json on validation failure for free, fixing the field-mapping repetition between `create()` and `update()` at the same time. Once stable, flip `--with-dto` to default-on.
- **Generated controller `findOr404` boilerplate.** `update()` and `delete()` both inline the find-or-404 problem+json response. Either factor a private helper into the template or migrate to Symfony's `#[MapEntity]` attribute (ships in 7.x).
- **Generated controller `findOr404` boilerplate.** `update()` and `delete()` still inline the find-or-404 problem+json response. Audit suggested factoring a private helper or migrating to Symfony's `#[MapEntity]` attribute. MapEntity changes the 404 response shape away from problem+json unless framework-level error config is updated; a private helper is net-zero on lines. Parking until either (a) we bake skeleton-level RFC 7807 error wiring (so MapEntity preserves shape), or (b) we flip `--with-dto` to default-on and the legacy template's polish becomes irrelevant.
- **Flip `--with-dto` to default-on.** Once snapshot-test churn settles and the DTO templates have surface-feedback, make it the default and gate `--no-dto` for users who want the legacy shape.
**Makers + reactive types (Phase 3.x deferred):**

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace PhpQml\Bridge\Tests;
use PhpQml\Bridge\BridgeOp;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(BridgeOp::class)]
final class BridgeOpTest extends TestCase
{
/**
* The four cases are the bridge's wire-format envelope `op` tokens
* (PLAN.md §4). QML clients hardcode the strings — renaming an enum
* case is a backwards-compatible PHP-side refactor, but renaming a
* `value` is not. This test fails the build before such a rename
* ships.
*/
public function testWireFormatValuesMatchDocumentedTokens(): void
{
self::assertSame('upsert', BridgeOp::Upsert->value);
self::assertSame('delete', BridgeOp::Delete->value);
self::assertSame('replace', BridgeOp::Replace->value);
self::assertSame('event', BridgeOp::Event->value);
}
}