From fdda75ff5646399ecee6b4ffc9d761c3f1681891 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 16 Jun 2026 13:14:57 -0300 Subject: [PATCH 1/2] feat: Record requests sent through the in-memory transport. --- README.md | 24 ++++++ src/Client/Transports/InMemoryTransport.php | 34 ++++++++- src/Internal/Client/RequestRecorder.php | 22 ++++++ .../Transports/InMemoryTransportTest.php | 73 +++++++++++++++++++ 4 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 src/Internal/Client/RequestRecorder.php diff --git a/README.md b/README.md index 3c9effa..fb8fc04 100644 --- a/README.md +++ b/README.md @@ -742,6 +742,30 @@ $http = Http::create() Calls consume responses in FIFO order. Exhaustion raises `NoMoreResponses`. +The transport records every request it receives, so a test can assert on the outbound request a consumer built +without a hand-written transport double: + +```php +send(request: Request::post(url: 'https://api.example.com/charges', body: ['amount' => 1000])); + +# The most recently received request, or null when none was received. +$lastReceived = $transport->lastReceivedRequest(); + +# Every received request, in the order they were sent. +$received = $transport->receivedRequests(); +``` + #### Extending with custom transports Implement `Transport` to add retry, logging, circuit breaker, or any other cross-cutting concern. The decorator wraps diff --git a/src/Client/Transports/InMemoryTransport.php b/src/Client/Transports/InMemoryTransport.php index ecbcee4..52f82b0 100644 --- a/src/Client/Transports/InMemoryTransport.php +++ b/src/Client/Transports/InMemoryTransport.php @@ -9,16 +9,18 @@ use TinyBlocks\Http\Client\Transport; use TinyBlocks\Http\Exceptions\NoMoreResponses; use TinyBlocks\Http\Internal\Client\Cursor; +use TinyBlocks\Http\Internal\Client\RequestRecorder; /** * In-memory {@see Transport} that serves pre-built responses from a FIFO queue. * - * Intended for use in tests and local development to avoid real network calls. - * Raises {@see NoMoreResponses} when the queue is exhausted. + * Intended for use in tests and local development to avoid real network calls. Records every + * request it receives so a consumer can assert on the outbound request it built. Raises + * {@see NoMoreResponses} when the queue is exhausted. */ final readonly class InMemoryTransport implements Transport { - private function __construct(private Cursor $cursor, private array $responses) + private function __construct(private Cursor $cursor, private RequestRecorder $recorder, private array $responses) { } @@ -30,11 +32,13 @@ private function __construct(private Cursor $cursor, private array $responses) */ public static function with(array $responses): InMemoryTransport { - return new InMemoryTransport(cursor: new Cursor(), responses: $responses); + return new InMemoryTransport(cursor: new Cursor(), recorder: new RequestRecorder(), responses: $responses); } public function send(Request $request): Response { + $this->recorder->record(request: $request); + $index = $this->cursor->advance(); if (!isset($this->responses[$index])) { @@ -43,4 +47,26 @@ public function send(Request $request): Response return $this->responses[$index]; } + + /** + * Returns the requests received by the transport, in the order they were sent. + * + * @return array The recorded outbound requests, oldest first. + */ + public function receivedRequests(): array + { + return $this->recorder->all(); + } + + /** + * Returns the most recently received request, or null when none was received. + * + * @return Request|null The last recorded outbound request, or null before any request was sent. + */ + public function lastReceivedRequest(): ?Request + { + $requests = $this->recorder->all(); + + return end($requests) ?: null; + } } diff --git a/src/Internal/Client/RequestRecorder.php b/src/Internal/Client/RequestRecorder.php new file mode 100644 index 0000000..c5f3d51 --- /dev/null +++ b/src/Internal/Client/RequestRecorder.php @@ -0,0 +1,22 @@ +requests; + } + + public function record(Request $request): void + { + $this->requests[] = $request; + } +} diff --git a/tests/Unit/Client/Transports/InMemoryTransportTest.php b/tests/Unit/Client/Transports/InMemoryTransportTest.php index 23e3d3f..f77e6fe 100644 --- a/tests/Unit/Client/Transports/InMemoryTransportTest.php +++ b/tests/Unit/Client/Transports/InMemoryTransportTest.php @@ -31,6 +31,40 @@ public function testSendWhenQueueExhaustedThenThrowsNoMoreResponses(): void $transport->send(request: $request); } + public function testSendWhenMultipleRequestsSentThenRecordsThemInOrder(): void + { + /** @Given a transport seeded with two responses */ + $transport = InMemoryTransport::with(responses: [ + Response::with(code: Code::OK), + Response::with(code: Code::CREATED) + ]); + + /** @And a first request to dispatch */ + $first = Request::get(url: '/dragons'); + + /** @And a second request to dispatch */ + $second = Request::post(url: '/dragons', body: ['name' => 'Smaug']); + + /** @When both requests are dispatched in order */ + $transport->send(request: $first); + $transport->send(request: $second); + + /** @Then the recorded requests preserve the dispatch order */ + self::assertSame([$first, $second], $transport->receivedRequests()); + } + + public function testLastReceivedRequestWhenNoRequestSentThenReturnsNull(): void + { + /** @Given a transport seeded with no responses */ + $transport = InMemoryTransport::with(responses: []); + + /** @When the last received request is read before any send */ + $lastReceived = $transport->lastReceivedRequest(); + + /** @Then no request has been recorded yet */ + self::assertNull($lastReceived); + } + public function testSendWhenMultipleResponsesQueuedThenServesInFifoOrder(): void { /** @Given a first queued response carrying OK */ @@ -71,6 +105,45 @@ public function testSendWhenQueueEmptyThenThrowsNoMoreResponsesImmediately(): vo $transport->send(request: $request); } + public function testSendWhenQueueExhaustedThenRecordsRequestBeforeThrowing(): void + { + /** @Given a transport seeded with no responses */ + $transport = InMemoryTransport::with(responses: []); + + /** @And a request to dispatch */ + $request = Request::get(url: '/dragons'); + + try { + /** @When sending the request against the exhausted queue */ + $transport->send(request: $request); + } catch (NoMoreResponses) { + /** @Then the request was recorded despite the exhausted queue */ + self::assertSame([$request], $transport->receivedRequests()); + } + } + + public function testLastReceivedRequestWhenRequestsSentThenReturnsMostRecent(): void + { + /** @Given a transport seeded with two responses */ + $transport = InMemoryTransport::with(responses: [ + Response::with(code: Code::OK), + Response::with(code: Code::CREATED) + ]); + + /** @And an initial request to dispatch */ + $initial = Request::get(url: '/dragons'); + + /** @And a most recent request to dispatch */ + $latest = Request::delete(url: '/dragons/1'); + + /** @When both requests are dispatched in order */ + $transport->send(request: $initial); + $transport->send(request: $latest); + + /** @Then the last received request is the most recently dispatched one */ + self::assertSame($latest, $transport->lastReceivedRequest()); + } + public function testSendWhenSingleResponseQueuedThenReturnsTheQueuedResponse(): void { /** @Given a transport seeded with a single CREATED response */ From eeebb001a6f3ab3d64b5ad161af6c2e5db2b2026 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Tue, 16 Jun 2026 13:15:03 -0300 Subject: [PATCH 2/2] chore: Add PHP conformance hooks for Claude Code edits. --- .claude/hooks/php-ordering-conformance.py | 607 ++++++++++++++++++ .../php-prose-punctuation-conformance.py | 176 +++++ .claude/settings.json | 17 + 3 files changed, 800 insertions(+) create mode 100644 .claude/hooks/php-ordering-conformance.py create mode 100644 .claude/hooks/php-prose-punctuation-conformance.py diff --git a/.claude/hooks/php-ordering-conformance.py b/.claude/hooks/php-ordering-conformance.py new file mode 100644 index 0000000..21a3ec0 --- /dev/null +++ b/.claude/hooks/php-ordering-conformance.py @@ -0,0 +1,607 @@ +#!/usr/bin/env python3 +"""PHP ordering conformance hook for tiny-blocks PHP libraries. + +Self-contained PostToolUse hook on Edit|Write|MultiEdit. Verifies the deterministic +ordering conventions for PHP declarations: + +- Parameter ordering: declaration parameters (constructors, factories, methods, + property promotion) in three tiers, required parameters first, then defaulted + parameters, then a variadic, each tier by identifier length ascending, + alphabetical tie-breaker, semantic pairs preserved. A PHPUnit test method fed by + a data provider is exempt, its parameters are the columns of its data set. +- Member ordering: constants, enum cases, constructor, static methods, instance + methods, in that group order, each group length-ascending with alphabetical + tie-breaker. PHPUnit test classes instead order methods as lifecycle hooks (in + execution order), then other methods, then data providers. + +The analysis is pure (FileUnit in, Violation out) and runs in three passes over +well-formed PHP: a lexical pass blanks every comment, string, and heredoc/nowdoc +body (LITERALS), a structural pass maps every bracket to its pair (bracket_spans); +extraction assigns tokens of interest to their containers by flat walks. Control +flow uses guard clauses only and nesting never exceeds two levels. Reports +violations to stderr and exits 2 to prompt Claude, exits 0 silently if no violations +or the file is out of scope. +""" + +import json +import re +import sys +from dataclasses import dataclass +from enum import Enum +from functools import cached_property +from pathlib import Path +from typing import Final + +# --- Configuration ---------------------------------------------------------- + +# In-scope files: PHP sources under src/ or tests/. +SCOPE_PATTERN: Final = re.compile(r"(^|/)(src|tests)/.+\.php$") + +# Semantic pairs (exhaustive). Natural order wins between +# the two members when both appear in the same parameter list. +SEMANTIC_PAIRS: Final = ( + ("start", "end"), + ("from", "to"), + ("startAt", "endAt"), + ("createdAt", "updatedAt"), + ("before", "after"), + ("min", "max"), +) + +# Each member maps to (first, second, position). Both members keep their natural +# order only when both are present, sorting as a unit at the lead member's key. +PAIR_MEMBER: Final = { + member: (first, second, position) + for first, second in SEMANTIC_PAIRS + for position, member in enumerate((first, second)) +} + +MODIFIERS: Final = ("abstract", "final", "private", "protected", "public", "static") + +# The lexical grammar: every PHP construct that must not be scanned as code. +# Alternatives are ordered, the heredoc label closes via backreference. +LITERALS: Final = re.compile( + r""" + /\*.*?\*/ # block comment + | //[^\n]* # line comment + | \#(?!\[)[^\n]* # hash comment, never a #[ attribute + | <<<[ \t]*(?P['"]?)(?P