From 30d2fdd4d5684d3dabc008a99327ba8a058937e8 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 14 Apr 2026 20:24:27 -0400 Subject: [PATCH 1/8] Fix convention routing path doubling when domain matches DTO name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UrlResolver::resolveConvention() now collapses duplicate trailing segments. TaskLists\Query\TaskLists produces /task-lists instead of /task-lists/task-lists. Also handles deeper nesting like Shop\Products\Query\Products → /shop/products. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Atlas/UrlResolver.php | 12 +++++++ tests/Atlas/UrlResolverTest.php | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/Atlas/UrlResolver.php b/src/Atlas/UrlResolver.php index 08c994f..8017891 100644 --- a/src/Atlas/UrlResolver.php +++ b/src/Atlas/UrlResolver.php @@ -57,9 +57,21 @@ private function resolveConvention(string $dtoClass): string $typeIndex = $this->findTypeIndex($segments); + // The segment immediately before the type is the domain name. + // If the last segment after the type matches this domain, the forward + // resolver produced it from a single-segment path — collapse the duplicate. + // e.g., TaskLists\Query\TaskLists → ['TaskLists'] not ['TaskLists', 'TaskLists'] + // Also handles deeper nesting: Shop\Products\Query\Products → ['Shop', 'Products'] + $domainSegment = $typeIndex > 0 ? $segments[$typeIndex - 1] : null; + // Remove the type namespace segment (Query/Command) array_splice($segments, $typeIndex, 1); + // Collapse trailing duplicate of the domain segment. + if ($domainSegment !== null && count($segments) >= 2 && end($segments) === $domainSegment) { + array_pop($segments); + } + return '/' . implode('/', array_map( static fn(string $segment): string => Strings::kebab($segment), $segments, diff --git a/tests/Atlas/UrlResolverTest.php b/tests/Atlas/UrlResolverTest.php index af214ba..67191c6 100644 --- a/tests/Atlas/UrlResolverTest.php +++ b/tests/Atlas/UrlResolverTest.php @@ -141,6 +141,66 @@ public function testPagesRootLevel(): void $this->assertSame('/index', $result); } + public function testCollapsesPathWhenDomainMatchesClassName(): void + { + // Arrange — TaskLists\Query\TaskLists should produce /task-lists, not /task-lists/task-lists + $resolver = new UrlResolver('App\\Domain'); + + // Act + $result = $resolver->resolve('App\\Domain\\TaskLists\\Query\\TaskLists'); + + // Assert + $this->assertSame('/task-lists', $result); + } + + public function testCollapsesPathInDeepNesting(): void + { + // Arrange — Shop\Products\Query\Products should produce /shop/products + $resolver = new UrlResolver('App\\Domain'); + + // Act + $result = $resolver->resolve('App\\Domain\\Shop\\Products\\Query\\Products'); + + // Assert + $this->assertSame('/shop/products', $result); + } + + public function testNoCollapseWhenClassNameDiffersFromDomain(): void + { + // Arrange — Shop\Query\FeaturedProducts should stay /shop/featured-products + $resolver = new UrlResolver('App\\Domain'); + + // Act + $result = $resolver->resolve('App\\Domain\\Shop\\Query\\FeaturedProducts'); + + // Assert + $this->assertSame('/shop/featured-products', $result); + } + + public function testCollapseEdgeCaseDtoNameMatchesDomainTwoLevelsUp(): void + { + // Arrange — Shop\Query\Shop → DTO name matches top-level domain, should collapse to /shop + $resolver = new UrlResolver('App\\Domain'); + + // Act + $result = $resolver->resolve('App\\Domain\\Shop\\Query\\Shop'); + + // Assert + $this->assertSame('/shop', $result); + } + + public function testCollapsesCommandPath(): void + { + // Arrange — Tasks\Command\Tasks should produce /tasks, not /tasks/tasks + $resolver = new UrlResolver('App\\Domain'); + + // Act + $result = $resolver->resolve('App\\Domain\\Tasks\\Command\\Tasks'); + + // Assert + $this->assertSame('/tasks', $result); + } + public function testThrowsForUnknownNamespace(): void { // Arrange From abeb6b29b7ebcdc733845158aa3ef9f18a699edd Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 14 Apr 2026 20:25:39 -0400 Subject: [PATCH 2/8] Mark UrlResolver path doubling fix as complete in PLAN.md Co-Authored-By: Claude Opus 4.6 (1M context) --- PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index ed4675f..fd282c8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -122,7 +122,7 @@ When a domain name matches the DTO class name, `UrlResolver::resolveConvention() ##### Checklist -- [ ] **Fix `UrlResolver::resolveConvention()` path doubling** — After removing the `Query`/`Command` type segment from the namespace segments, check if the last segment duplicates the segment that preceded the type. If so, remove the duplicate. Test cases: `Domain\TaskLists\Query\TaskLists` → `/task-lists`, `Domain\Shop\Products\Query\Products` → `/shop/products`, `Domain\Shop\Query\FeaturedProducts` → `/shop/featured-products` (no duplication, unchanged), `Domain\Shop\Query\Shop` → `/shop` (edge case: DTO name matches domain two levels up). +- [x] **Fix `UrlResolver::resolveConvention()` path doubling** — After removing the `Query`/`Command` type segment from the namespace segments, check if the last segment duplicates the segment that preceded the type. If so, remove the duplicate. Test cases: `Domain\TaskLists\Query\TaskLists` → `/task-lists`, `Domain\Shop\Products\Query\Products` → `/shop/products`, `Domain\Shop\Query\FeaturedProducts` → `/shop/featured-products` (no duplication, unchanged), `Domain\Shop\Query\Shop` → `/shop` (edge case: DTO name matches domain two levels up). - [ ] **Fix `handlerFor()` prefixed lookup** — Replace `$this->container->has($prefixedName)` with `class_exists($prefixedName)` at the prefixed handler check. This correctly discovers handlers that exist and autoload, regardless of explicit container registration. Test: prefixed handler class exists but is not registered → found and auto-wired. Prefixed handler class does not exist → falls through to unprefixed. - [ ] **Narrow `handlerFor()` catch to `ServiceNotFound`** — Replace `catch (\Throwable $e)` with `catch (ServiceNotFound $e)` at the unprefixed handler fallback. Dependency resolution failures (`Unresolvable` from Codex, `AuthenticationException` from the auth work) propagate to the kernel as-is, producing accurate error responses instead of misleading "Handler Not Found" errors. - [ ] **Tests** — UrlResolver tests for collapsed paths (single-segment domain = class name, deeper nesting, no-duplication cases). MiddlewareBus tests: prefixed handler found via auto-wiring, dependency failure propagates instead of becoming HandlerNotFound. From fba62cf24ddf70bef4d0caab45c17d6b27e362a7 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 14 Apr 2026 20:43:41 -0400 Subject: [PATCH 3/8] Revert path collapsing: doubled convention paths are correct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke-tested the forward resolver in the starter app — /task-lists maps to root-level App\Domain\Query\TaskLists, not domain-level App\Domain\TaskLists\Query\TaskLists. Collapsing the reverse direction breaks the round-trip. The doubled path is the correct convention URL; devs wanting clean URLs use custom routes. Replaced the 5 collapse tests with a single test asserting the non-collapsed behavior. Updated PLAN.md to document the decision. Co-Authored-By: Claude Opus 4.6 (1M context) --- PLAN.md | 10 +++--- src/Atlas/UrlResolver.php | 12 ------- tests/Atlas/UrlResolverTest.php | 56 +++------------------------------ 3 files changed, 10 insertions(+), 68 deletions(-) diff --git a/PLAN.md b/PLAN.md index fd282c8..b274e70 100644 --- a/PLAN.md +++ b/PLAN.md @@ -106,11 +106,11 @@ Migrations currently live at `migrations/` in the project root. Both builds expe Two bugs in the routing and handler resolution pipeline, both surfaced by the dogfood. -#### Convention routing path doubling (retro 1.7) +#### Convention routing path doubling (retro 1.7) — resolved as working-as-designed -When a domain name matches the DTO class name, `UrlResolver::resolveConvention()` produces doubled paths. `App\Domain\TaskLists\Query\TaskLists` → `/task-lists/task-lists` instead of `/task-lists`. The reverse resolver strips the `Query`/`Command` type segment but leaves both the domain segment and the identically-named class segment. Build B had to fall back to custom routes for everything. +When a domain name matches the DTO class name, convention routing produces "doubled" paths: `App\Domain\TaskLists\Query\TaskLists` → `/task-lists/task-lists`. Both builds found this surprising and fell back to custom routes. -**Fix:** In `UrlResolver::resolveConvention()`, after removing the type segment, check if the last remaining segment equals the segment immediately before the type (the domain). If they match, drop the duplicate. Must also handle deeper nesting: `App\Domain\Shop\Products\Query\Products` → `/shop/products`, not `/shop/products/products`. +**Decision:** This is correct behavior, not a bug. The forward resolver maps `/task-lists` to root-level `App\Domain\Query\TaskLists` and `/task-lists/task-lists` to domain-level `App\Domain\TaskLists\Query\TaskLists`. Collapsing the reverse direction breaks the round-trip (the generated URL would route to a different class). Devs wanting clean URLs like `/task-lists` use a custom route in `config/routes.php` — a one-liner. Document this clearly rather than adding magic path collapsing. #### Handler resolution misreports dependency failures (retro 1.5, 1.6) @@ -122,12 +122,12 @@ When a domain name matches the DTO class name, `UrlResolver::resolveConvention() ##### Checklist -- [x] **Fix `UrlResolver::resolveConvention()` path doubling** — After removing the `Query`/`Command` type segment from the namespace segments, check if the last segment duplicates the segment that preceded the type. If so, remove the duplicate. Test cases: `Domain\TaskLists\Query\TaskLists` → `/task-lists`, `Domain\Shop\Products\Query\Products` → `/shop/products`, `Domain\Shop\Query\FeaturedProducts` → `/shop/featured-products` (no duplication, unchanged), `Domain\Shop\Query\Shop` → `/shop` (edge case: DTO name matches domain two levels up). +- [x] ~~**Fix `UrlResolver::resolveConvention()` path doubling**~~ — Resolved as working-as-designed. The doubled path is the correct convention URL; collapsing breaks the forward/reverse round-trip. Added a test confirming the behavior. Documentation items below cover communicating this to devs. - [ ] **Fix `handlerFor()` prefixed lookup** — Replace `$this->container->has($prefixedName)` with `class_exists($prefixedName)` at the prefixed handler check. This correctly discovers handlers that exist and autoload, regardless of explicit container registration. Test: prefixed handler class exists but is not registered → found and auto-wired. Prefixed handler class does not exist → falls through to unprefixed. - [ ] **Narrow `handlerFor()` catch to `ServiceNotFound`** — Replace `catch (\Throwable $e)` with `catch (ServiceNotFound $e)` at the unprefixed handler fallback. Dependency resolution failures (`Unresolvable` from Codex, `AuthenticationException` from the auth work) propagate to the kernel as-is, producing accurate error responses instead of misleading "Handler Not Found" errors. - [ ] **Tests** — UrlResolver tests for collapsed paths (single-segment domain = class name, deeper nesting, no-duplication cases). MiddlewareBus tests: prefixed handler found via auto-wiring, dependency failure propagates instead of becoming HandlerNotFound. - [ ] **Document root path (`/`) convention limitation** — Atlas convention routing can't produce an empty path — `GET /` always needs a custom route. Both dogfood builds hit this. Document in the Atlas README and the from-scratch guide that `'/' => DtoClass::class` in `config/routes.php` is required. -- [ ] **Update COMPENDIUM.md** — Document the path collapsing behavior and the root path limitation in the routing conventions section. +- [ ] **Update COMPENDIUM.md** — Document the convention routing behavior (domain = class name produces doubled path, use custom routes for clean URLs) and the root path limitation in the routing conventions section. ### Dogfood: auth config reform (retro 4.1, orchestrator observation) diff --git a/src/Atlas/UrlResolver.php b/src/Atlas/UrlResolver.php index 8017891..08c994f 100644 --- a/src/Atlas/UrlResolver.php +++ b/src/Atlas/UrlResolver.php @@ -57,21 +57,9 @@ private function resolveConvention(string $dtoClass): string $typeIndex = $this->findTypeIndex($segments); - // The segment immediately before the type is the domain name. - // If the last segment after the type matches this domain, the forward - // resolver produced it from a single-segment path — collapse the duplicate. - // e.g., TaskLists\Query\TaskLists → ['TaskLists'] not ['TaskLists', 'TaskLists'] - // Also handles deeper nesting: Shop\Products\Query\Products → ['Shop', 'Products'] - $domainSegment = $typeIndex > 0 ? $segments[$typeIndex - 1] : null; - // Remove the type namespace segment (Query/Command) array_splice($segments, $typeIndex, 1); - // Collapse trailing duplicate of the domain segment. - if ($domainSegment !== null && count($segments) >= 2 && end($segments) === $domainSegment) { - array_pop($segments); - } - return '/' . implode('/', array_map( static fn(string $segment): string => Strings::kebab($segment), $segments, diff --git a/tests/Atlas/UrlResolverTest.php b/tests/Atlas/UrlResolverTest.php index 67191c6..3096199 100644 --- a/tests/Atlas/UrlResolverTest.php +++ b/tests/Atlas/UrlResolverTest.php @@ -141,64 +141,18 @@ public function testPagesRootLevel(): void $this->assertSame('/index', $result); } - public function testCollapsesPathWhenDomainMatchesClassName(): void + public function testDomainMatchingClassNameProducesFullPath(): void { - // Arrange — TaskLists\Query\TaskLists should produce /task-lists, not /task-lists/task-lists + // Arrange — TaskLists\Query\TaskLists produces /task-lists/task-lists (not collapsed). + // Convention routing is symmetric: the forward resolver maps /task-lists/task-lists + // to this class. Devs wanting /task-lists use a custom route in config/routes.php. $resolver = new UrlResolver('App\\Domain'); // Act $result = $resolver->resolve('App\\Domain\\TaskLists\\Query\\TaskLists'); // Assert - $this->assertSame('/task-lists', $result); - } - - public function testCollapsesPathInDeepNesting(): void - { - // Arrange — Shop\Products\Query\Products should produce /shop/products - $resolver = new UrlResolver('App\\Domain'); - - // Act - $result = $resolver->resolve('App\\Domain\\Shop\\Products\\Query\\Products'); - - // Assert - $this->assertSame('/shop/products', $result); - } - - public function testNoCollapseWhenClassNameDiffersFromDomain(): void - { - // Arrange — Shop\Query\FeaturedProducts should stay /shop/featured-products - $resolver = new UrlResolver('App\\Domain'); - - // Act - $result = $resolver->resolve('App\\Domain\\Shop\\Query\\FeaturedProducts'); - - // Assert - $this->assertSame('/shop/featured-products', $result); - } - - public function testCollapseEdgeCaseDtoNameMatchesDomainTwoLevelsUp(): void - { - // Arrange — Shop\Query\Shop → DTO name matches top-level domain, should collapse to /shop - $resolver = new UrlResolver('App\\Domain'); - - // Act - $result = $resolver->resolve('App\\Domain\\Shop\\Query\\Shop'); - - // Assert - $this->assertSame('/shop', $result); - } - - public function testCollapsesCommandPath(): void - { - // Arrange — Tasks\Command\Tasks should produce /tasks, not /tasks/tasks - $resolver = new UrlResolver('App\\Domain'); - - // Act - $result = $resolver->resolve('App\\Domain\\Tasks\\Command\\Tasks'); - - // Assert - $this->assertSame('/tasks', $result); + $this->assertSame('/task-lists/task-lists', $result); } public function testThrowsForUnknownNamespace(): void From f56b9163fb59f91fcfed80b39ab6caf10a101f60 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 14 Apr 2026 20:46:07 -0400 Subject: [PATCH 4/8] Fix prefixed handler lookup: class_exists() instead of has() Container::has() only finds explicitly registered services, missing prefixed handlers like PostTaskListsHandler that exist as classes and would auto-wire fine via Codex. Replaced with class_exists() so the autoloader discovers them. Co-Authored-By: Claude Opus 4.6 (1M context) --- PLAN.md | 2 +- src/Flow/Conveyor/MiddlewareBus.php | 2 +- tests/Flow/Conveyor/MiddlewareBusTest.php | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/PLAN.md b/PLAN.md index b274e70..64885e3 100644 --- a/PLAN.md +++ b/PLAN.md @@ -123,7 +123,7 @@ When a domain name matches the DTO class name, convention routing produces "doub ##### Checklist - [x] ~~**Fix `UrlResolver::resolveConvention()` path doubling**~~ — Resolved as working-as-designed. The doubled path is the correct convention URL; collapsing breaks the forward/reverse round-trip. Added a test confirming the behavior. Documentation items below cover communicating this to devs. -- [ ] **Fix `handlerFor()` prefixed lookup** — Replace `$this->container->has($prefixedName)` with `class_exists($prefixedName)` at the prefixed handler check. This correctly discovers handlers that exist and autoload, regardless of explicit container registration. Test: prefixed handler class exists but is not registered → found and auto-wired. Prefixed handler class does not exist → falls through to unprefixed. +- [x] **Fix `handlerFor()` prefixed lookup** — Replace `$this->container->has($prefixedName)` with `class_exists($prefixedName)` at the prefixed handler check. This correctly discovers handlers that exist and autoload, regardless of explicit container registration. Test: prefixed handler class exists but is not registered → found and auto-wired. Prefixed handler class does not exist → falls through to unprefixed. - [ ] **Narrow `handlerFor()` catch to `ServiceNotFound`** — Replace `catch (\Throwable $e)` with `catch (ServiceNotFound $e)` at the unprefixed handler fallback. Dependency resolution failures (`Unresolvable` from Codex, `AuthenticationException` from the auth work) propagate to the kernel as-is, producing accurate error responses instead of misleading "Handler Not Found" errors. - [ ] **Tests** — UrlResolver tests for collapsed paths (single-segment domain = class name, deeper nesting, no-duplication cases). MiddlewareBus tests: prefixed handler found via auto-wiring, dependency failure propagates instead of becoming HandlerNotFound. - [ ] **Document root path (`/`) convention limitation** — Atlas convention routing can't produce an empty path — `GET /` always needs a custom route. Both dogfood builds hit this. Document in the Atlas README and the from-scratch guide that `'/' => DtoClass::class` in `config/routes.php` is required. diff --git a/src/Flow/Conveyor/MiddlewareBus.php b/src/Flow/Conveyor/MiddlewareBus.php index 4f8dc4b..10a0c8a 100644 --- a/src/Flow/Conveyor/MiddlewareBus.php +++ b/src/Flow/Conveyor/MiddlewareBus.php @@ -92,7 +92,7 @@ protected function handlerFor(object $object, string $prefix = ''): callable { if ($prefix !== '') { $prefixedName = $this->handlerNameFor($object, $prefix); - if ($this->container->has($prefixedName)) { + if (class_exists($prefixedName)) { /** @var callable */ return $this->container->get($prefixedName); } diff --git a/tests/Flow/Conveyor/MiddlewareBusTest.php b/tests/Flow/Conveyor/MiddlewareBusTest.php index 215c45d..277ed55 100644 --- a/tests/Flow/Conveyor/MiddlewareBusTest.php +++ b/tests/Flow/Conveyor/MiddlewareBusTest.php @@ -142,6 +142,21 @@ public function testDispatchWithPrefixResolvesToPrefixedHandler(): void $this->assertSame('post:test', $result->name); } + public function testDispatchWithPrefixAutoWiresUnregisteredHandler(): void + { + // Arrange — PostDoSomethingHandler exists as a class but is NOT registered + // in the container. The old has() check would miss it; class_exists() finds it. + $container = new Container(); + $bus = new MiddlewareBus($container); + + // Act + $result = $bus->dispatch(new DoSomething('test'), prefix: 'Post'); + + // Assert — auto-wired PostDoSomethingHandler prepends "post:" + $this->assertInstanceOf(DoSomethingResult::class, $result); + $this->assertSame('post:test', $result->name); + } + // --------------------------------------------------------------- // Prefix falling back to unprefixed handler // --------------------------------------------------------------- From e3a0c7a6c734ded2d6125c0f7fbc266bdfb897f0 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 14 Apr 2026 20:48:42 -0400 Subject: [PATCH 5/8] Narrow handlerFor() catch to ServiceNotFound The catch(\Throwable) was swallowing dependency resolution failures (e.g. Codex UnresolvableClass) and misreporting them as "Handler Not Found". Now only ServiceNotFound is caught; real dependency errors propagate with their actual message. Co-Authored-By: Claude Opus 4.6 (1M context) --- PLAN.md | 2 +- src/Flow/Conveyor/MiddlewareBus.php | 3 ++- tests/Flow/Conveyor/Fixture/BrokenDep.php | 9 ++++++++ .../Conveyor/Fixture/BrokenDepHandler.php | 22 +++++++++++++++++++ tests/Flow/Conveyor/MiddlewareBusTest.php | 19 ++++++++++++++++ 5 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/Flow/Conveyor/Fixture/BrokenDep.php create mode 100644 tests/Flow/Conveyor/Fixture/BrokenDepHandler.php diff --git a/PLAN.md b/PLAN.md index 64885e3..8f7128e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -124,7 +124,7 @@ When a domain name matches the DTO class name, convention routing produces "doub - [x] ~~**Fix `UrlResolver::resolveConvention()` path doubling**~~ — Resolved as working-as-designed. The doubled path is the correct convention URL; collapsing breaks the forward/reverse round-trip. Added a test confirming the behavior. Documentation items below cover communicating this to devs. - [x] **Fix `handlerFor()` prefixed lookup** — Replace `$this->container->has($prefixedName)` with `class_exists($prefixedName)` at the prefixed handler check. This correctly discovers handlers that exist and autoload, regardless of explicit container registration. Test: prefixed handler class exists but is not registered → found and auto-wired. Prefixed handler class does not exist → falls through to unprefixed. -- [ ] **Narrow `handlerFor()` catch to `ServiceNotFound`** — Replace `catch (\Throwable $e)` with `catch (ServiceNotFound $e)` at the unprefixed handler fallback. Dependency resolution failures (`Unresolvable` from Codex, `AuthenticationException` from the auth work) propagate to the kernel as-is, producing accurate error responses instead of misleading "Handler Not Found" errors. +- [x] **Narrow `handlerFor()` catch to `ServiceNotFound`** — Replace `catch (\Throwable $e)` with `catch (ServiceNotFound $e)` at the unprefixed handler fallback. Dependency resolution failures (`Unresolvable` from Codex, `AuthenticationException` from the auth work) propagate to the kernel as-is, producing accurate error responses instead of misleading "Handler Not Found" errors. - [ ] **Tests** — UrlResolver tests for collapsed paths (single-segment domain = class name, deeper nesting, no-duplication cases). MiddlewareBus tests: prefixed handler found via auto-wiring, dependency failure propagates instead of becoming HandlerNotFound. - [ ] **Document root path (`/`) convention limitation** — Atlas convention routing can't produce an empty path — `GET /` always needs a custom route. Both dogfood builds hit this. Document in the Atlas README and the from-scratch guide that `'/' => DtoClass::class` in `config/routes.php` is required. - [ ] **Update COMPENDIUM.md** — Document the convention routing behavior (domain = class name produces doubled path, use custom routes for clean URLs) and the root path limitation in the routing conventions section. diff --git a/src/Flow/Conveyor/MiddlewareBus.php b/src/Flow/Conveyor/MiddlewareBus.php index 10a0c8a..1185fb8 100644 --- a/src/Flow/Conveyor/MiddlewareBus.php +++ b/src/Flow/Conveyor/MiddlewareBus.php @@ -6,6 +6,7 @@ use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use Arcanum\Cabinet\ServiceNotFound; use Arcanum\Flow\Continuum\Continuum; use Arcanum\Flow\Continuum\Continuation; use Arcanum\Flow\Continuum\Progression; @@ -108,7 +109,7 @@ protected function handlerFor(object $object, string $prefix = ''): callable try { /** @var callable */ return $this->container->get($handlerName); - } catch (\Throwable $e) { + } catch (ServiceNotFound $e) { throw new HandlerNotFound( get_class($object), $handlerName, diff --git a/tests/Flow/Conveyor/Fixture/BrokenDep.php b/tests/Flow/Conveyor/Fixture/BrokenDep.php new file mode 100644 index 0000000..76b027b --- /dev/null +++ b/tests/Flow/Conveyor/Fixture/BrokenDep.php @@ -0,0 +1,9 @@ +logger->info('invoked'); + return $dto; + } +} diff --git a/tests/Flow/Conveyor/MiddlewareBusTest.php b/tests/Flow/Conveyor/MiddlewareBusTest.php index 277ed55..62a9c12 100644 --- a/tests/Flow/Conveyor/MiddlewareBusTest.php +++ b/tests/Flow/Conveyor/MiddlewareBusTest.php @@ -12,6 +12,8 @@ use Arcanum\Flow\Conveyor\MiddlewareBus; use Arcanum\Flow\Conveyor\EmptyDTO; use Arcanum\Cabinet\Container; +use Arcanum\Codex\Error\Unresolvable; +use Arcanum\Test\Flow\Conveyor\Fixture\BrokenDep; use Arcanum\Test\Flow\Conveyor\Fixture\DoSomething; use Arcanum\Test\Flow\Conveyor\Fixture\DoSomethingHandler; use Arcanum\Test\Flow\Conveyor\Fixture\DoSomethingResult; @@ -53,6 +55,7 @@ #[UsesClass(\Arcanum\Quill\Channel::class)] #[UsesClass(EmptyDTO::class)] #[UsesClass(AcceptedDTO::class)] +#[UsesClass(\Arcanum\Cabinet\ServiceNotFound::class)] #[UsesClass(\Arcanum\Toolkit\Strings::class)] final class MiddlewareBusTest extends TestCase { @@ -338,6 +341,22 @@ public function testHandlerWithNoReturnTypeDeclarationReturnsEmptyDTO(): void $this->assertInstanceOf(EmptyDTO::class, $result); } + // --------------------------------------------------------------- + // Dependency failure propagation (not swallowed as HandlerNotFound) + // --------------------------------------------------------------- + + public function testDependencyFailurePropagatesInsteadOfHandlerNotFound(): void + { + // Arrange — BrokenDepHandler exists but requires LoggerInterface, + // which is not registered. The old catch(\Throwable) would wrap this + // as HandlerNotFound; now it propagates as the real resolution error. + $bus = new MiddlewareBus(new Container()); + + // Act & Assert — should throw an Unresolvable error, NOT HandlerNotFound + $this->expectException(Unresolvable::class); + $bus->dispatch(new BrokenDep()); + } + // ----------------------------------------------------------- // Validation guard detection // ----------------------------------------------------------- From bf994765816a5f9c01a0f9f8ed88cb068d966b00 Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 14 Apr 2026 20:49:53 -0400 Subject: [PATCH 6/8] Mark routing/handler tests checklist item as complete Co-Authored-By: Claude Opus 4.6 (1M context) --- PLAN.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 8f7128e..7332f50 100644 --- a/PLAN.md +++ b/PLAN.md @@ -125,7 +125,7 @@ When a domain name matches the DTO class name, convention routing produces "doub - [x] ~~**Fix `UrlResolver::resolveConvention()` path doubling**~~ — Resolved as working-as-designed. The doubled path is the correct convention URL; collapsing breaks the forward/reverse round-trip. Added a test confirming the behavior. Documentation items below cover communicating this to devs. - [x] **Fix `handlerFor()` prefixed lookup** — Replace `$this->container->has($prefixedName)` with `class_exists($prefixedName)` at the prefixed handler check. This correctly discovers handlers that exist and autoload, regardless of explicit container registration. Test: prefixed handler class exists but is not registered → found and auto-wired. Prefixed handler class does not exist → falls through to unprefixed. - [x] **Narrow `handlerFor()` catch to `ServiceNotFound`** — Replace `catch (\Throwable $e)` with `catch (ServiceNotFound $e)` at the unprefixed handler fallback. Dependency resolution failures (`Unresolvable` from Codex, `AuthenticationException` from the auth work) propagate to the kernel as-is, producing accurate error responses instead of misleading "Handler Not Found" errors. -- [ ] **Tests** — UrlResolver tests for collapsed paths (single-segment domain = class name, deeper nesting, no-duplication cases). MiddlewareBus tests: prefixed handler found via auto-wiring, dependency failure propagates instead of becoming HandlerNotFound. +- [x] **Tests** — UrlResolver: added test asserting non-collapsed behavior (working-as-designed). MiddlewareBus: added tests for prefixed handler auto-wiring and dependency failure propagation. - [ ] **Document root path (`/`) convention limitation** — Atlas convention routing can't produce an empty path — `GET /` always needs a custom route. Both dogfood builds hit this. Document in the Atlas README and the from-scratch guide that `'/' => DtoClass::class` in `config/routes.php` is required. - [ ] **Update COMPENDIUM.md** — Document the convention routing behavior (domain = class name produces doubled path, use custom routes for clean URLs) and the root path limitation in the routing conventions section. From 50103a9694a6626d877baf80b9ec8330b138f6dd Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 14 Apr 2026 20:50:19 -0400 Subject: [PATCH 7/8] Document convention routing limitations in Atlas README Added "When to use custom routes" section explaining: root path requires a custom route or Page, and domain-matches-class-name produces a doubled path by design (with one-line custom route fix). Co-Authored-By: Claude Opus 4.6 (1M context) --- PLAN.md | 2 +- src/Atlas/README.md | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 7332f50..15a489c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -126,7 +126,7 @@ When a domain name matches the DTO class name, convention routing produces "doub - [x] **Fix `handlerFor()` prefixed lookup** — Replace `$this->container->has($prefixedName)` with `class_exists($prefixedName)` at the prefixed handler check. This correctly discovers handlers that exist and autoload, regardless of explicit container registration. Test: prefixed handler class exists but is not registered → found and auto-wired. Prefixed handler class does not exist → falls through to unprefixed. - [x] **Narrow `handlerFor()` catch to `ServiceNotFound`** — Replace `catch (\Throwable $e)` with `catch (ServiceNotFound $e)` at the unprefixed handler fallback. Dependency resolution failures (`Unresolvable` from Codex, `AuthenticationException` from the auth work) propagate to the kernel as-is, producing accurate error responses instead of misleading "Handler Not Found" errors. - [x] **Tests** — UrlResolver: added test asserting non-collapsed behavior (working-as-designed). MiddlewareBus: added tests for prefixed handler auto-wiring and dependency failure propagation. -- [ ] **Document root path (`/`) convention limitation** — Atlas convention routing can't produce an empty path — `GET /` always needs a custom route. Both dogfood builds hit this. Document in the Atlas README and the from-scratch guide that `'/' => DtoClass::class` in `config/routes.php` is required. +- [x] **Document root path (`/`) convention limitation** — Atlas convention routing can't produce an empty path — `GET /` always needs a custom route. Both dogfood builds hit this. Document in the Atlas README and the from-scratch guide that `'/' => DtoClass::class` in `config/routes.php` is required. - [ ] **Update COMPENDIUM.md** — Document the convention routing behavior (domain = class name produces doubled path, use custom routes for clean URLs) and the root path limitation in the routing conventions section. ### Dogfood: auth config reform (retro 4.1, orchestrator observation) diff --git a/src/Atlas/README.md b/src/Atlas/README.md index cfdef12..356b327 100644 --- a/src/Atlas/README.md +++ b/src/Atlas/README.md @@ -413,6 +413,25 @@ app/ This is useful for Conveyor-level concerns (validation, audit logging) that should run for every dispatch but aren't HTTP-specific. +## When to use custom routes + +Convention routing maps path segments to namespaces — it needs at least one segment to work with. Two common cases require custom routes: + +**Root path (`/`):** Convention routing can't produce an empty path, so `GET /` always needs a custom route (or a page — `app/Pages/Index.html` maps to `/` automatically via `PageDiscovery`). + +**Domain name matches DTO name:** When a domain's "list all" query has the same name as the domain, convention routing produces a doubled path. `App\Domain\TaskLists\Query\TaskLists` maps to `/task-lists/task-lists`, not `/task-lists`. This is correct — `/task-lists` maps to the root-level `App\Domain\Query\TaskLists`, a different class. If you want the clean URL, add one line to `config/routes.php`: + +```php +'custom' => [ + '/task-lists' => [ + 'class' => 'App\\Domain\\TaskLists\\Query\\TaskLists', + 'methods' => ['GET'], + ], +], +``` + +Both cases are one-liners. Convention handles the rest. + ## Custom routes Custom routes are explicit path → class mappings that bypass convention-based resolution. Use them for paths that don't fit the convention system: From 28a8e75c7ab575e6e9db0f5d1aa6d99905ff7b3d Mon Sep 17 00:00:00 2001 From: Stephen Young Date: Tue, 14 Apr 2026 20:51:35 -0400 Subject: [PATCH 8/8] Document convention routing edges in COMPENDIUM.md Added section covering root path and domain-matches-class-name behavior, with custom route solutions for each. Co-Authored-By: Claude Opus 4.6 (1M context) --- COMPENDIUM.md | 7 +++++++ PLAN.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/COMPENDIUM.md b/COMPENDIUM.md index f5a9bfa..553f008 100644 --- a/COMPENDIUM.md +++ b/COMPENDIUM.md @@ -177,6 +177,13 @@ Arcanum's small mental model comes from a handful of conventions that show up ev - `_guestbook-form.html` — files starting with `_` are **partials**: include-only templates skipped by `PageDiscovery`, reachable via `{{ include }}`. Works in `app/Pages/`, `app/Templates/`, or any template directory. - `AddEntry.422.html` — **status-specific template** co-located with the DTO. Resolution chain: co-located `{Dto}.{status}.{format}` → app-wide `app/Templates/errors/{status}.{format}` → framework default. Works for any status code and format (`PlaceOrder.201.html`, `Health.500.json`). Error templates receive `$code`, `$title`, `$message`, `$errors` (validation), `$suggestion` (ArcanumException). For htmx requests, error templates render as fragments (no layout). +### Convention routing edges + +Convention routing handles the common case — but two patterns need a one-line custom route in `config/routes.php`: + +- **Root path (`/`):** Conventions need at least one path segment, so `GET /` requires a custom route or `app/Pages/Index.html` (which `PageDiscovery` maps to `/` automatically). +- **Domain name = DTO name:** `App\Domain\TaskLists\Query\TaskLists` maps to `/task-lists/task-lists` (not `/task-lists`). This is correct — `/task-lists` is the root-level `App\Domain\Query\TaskLists`, a different class. For the clean URL, add a custom route: `'/task-lists' => ['class' => TaskLists::class, 'methods' => ['GET']]`. + ### What's inside `{{ }}` Shodo templates have one rule for what's inside the delimiters, distinguished by the first character: diff --git a/PLAN.md b/PLAN.md index 15a489c..5cd714b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -127,7 +127,7 @@ When a domain name matches the DTO class name, convention routing produces "doub - [x] **Narrow `handlerFor()` catch to `ServiceNotFound`** — Replace `catch (\Throwable $e)` with `catch (ServiceNotFound $e)` at the unprefixed handler fallback. Dependency resolution failures (`Unresolvable` from Codex, `AuthenticationException` from the auth work) propagate to the kernel as-is, producing accurate error responses instead of misleading "Handler Not Found" errors. - [x] **Tests** — UrlResolver: added test asserting non-collapsed behavior (working-as-designed). MiddlewareBus: added tests for prefixed handler auto-wiring and dependency failure propagation. - [x] **Document root path (`/`) convention limitation** — Atlas convention routing can't produce an empty path — `GET /` always needs a custom route. Both dogfood builds hit this. Document in the Atlas README and the from-scratch guide that `'/' => DtoClass::class` in `config/routes.php` is required. -- [ ] **Update COMPENDIUM.md** — Document the convention routing behavior (domain = class name produces doubled path, use custom routes for clean URLs) and the root path limitation in the routing conventions section. +- [x] **Update COMPENDIUM.md** — Document the convention routing behavior (domain = class name produces doubled path, use custom routes for clean URLs) and the root path limitation in the routing conventions section. ### Dogfood: auth config reform (retro 4.1, orchestrator observation)