From 8fd6b6d579609dd7a9d9b3f449ddd25743bc69e6 Mon Sep 17 00:00:00 2001 From: "Beau Beauchamp, WebTigers" Date: Mon, 17 Aug 2026 04:22:39 -0400 Subject: [PATCH] Routing: ingest module configs/routes.ini centrally (the missing consumer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module configs/routes.ini had NO consumer — buildConfig() merges only core + app routes.ini, so a module's pretty URL only worked if it called $router->addRoute() from its Bootstrap (config-as-code). That left agent's /admin/settings/agent/skills route (and register's .well-known/tiger-verify.txt) dead. Add the missing per-type consumer, mirroring how acl.ini/navigation.ini/schedule.ini are already discovered: - Tiger_Routing_ModuleRoutes::collect()/apply() — scan every ACTIVE module's configs/routes.ini, namespace each route __ (collision-proof; a module can't stomp core or a peer; app-dir overrides same-slug core-dir), and register via ZF1's native route factory (honors type, preserves newest-first order). - Tiger_Application_Bootstrap::_initModuleRoutes() — the seam: runs after the kernel (/api,/auth) routes, reads the active set from the DB (graceful pre-DB), applies. Works under the reserved /admin prefix (a native router route is matched before dispatch), which Tiger_Routing_Overrides cannot claim. - Convert blog from _initBlogRoutes ($router->addRoute × 5) to a declarative modules/blog/configs/routes.ini — no more route code in a Bootstrap. - Now live-registered at boot (verified on dev): agent__agentSkillsAdmin, blog__blog{Single,Category,Tag,Feed,Admin}, register__tigerVerify. /admin/settings/agent/skills resolves (was 404); /blog/post, /api, /admin intact. - Tests: collect() namespacing/inactive-skip/app-override (unit); blog routes.ini contract + ordering (integration). ROUTING.md documents the three route homes. Co-Authored-By: Claude Opus 4.8 --- CAPABILITIES.md | 3 +- ROUTING.md | 23 +++- library/Tiger/Application/Bootstrap.php | 33 ++++++ library/Tiger/Routing/ModuleRoutes.php | 88 ++++++++++++++++ modules/blog/Bootstrap.php | 30 +----- modules/blog/configs/routes.ini | 38 +++++++ tests/Integration/Blog/BlogBootstrapTest.php | 34 ++++-- tests/Unit/Routing/ModuleRoutesTest.php | 105 +++++++++++++++++++ 8 files changed, 309 insertions(+), 45 deletions(-) create mode 100644 library/Tiger/Routing/ModuleRoutes.php create mode 100644 modules/blog/configs/routes.ini create mode 100644 tests/Unit/Routing/ModuleRoutesTest.php diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 7835dd8..11ba212 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -5,7 +5,7 @@ > before assuming something isn't built. `@api` = stable to build on; `@internal` = may change. > Grouped by **capability** (across layers), not by directory. -**175 classes** across **31 capabilities** · **17 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). +**176 classes** across **31 capabilities** · **17 modules**. Full prose: [FEATURES.md](FEATURES.md) (what) · [ARCHITECTURE.md](ARCHITECTURE.md) (why). Not-yet-built: [BACKLOG.md](BACKLOG.md). ## Capabilities (`library/Tiger`) @@ -264,6 +264,7 @@ - **Tiger_Controller_Plugin_RouteOverride** `@api` — apply declared pretty-route overrides. · `library/Tiger/Controller/Plugin/RouteOverride.php` - **Tiger_Controller_Plugin_ScheduleTick** `@internal` — the WordPress-style pseudo-cron for Tiger_Schedule. · `library/Tiger/Controller/Plugin/ScheduleTick.php` - **Tiger_Controller_Plugin_ThemeContent** `@api` — serve a theme's BUNDLED STATIC pages. · `library/Tiger/Controller/Plugin/ThemeContent.php` +- **Tiger_Routing_ModuleRoutes** `@api` — ingests every ACTIVE module's `configs/routes.ini` into the router. · `library/Tiger/Routing/ModuleRoutes.php` - **Tiger_Routing_Overrides** `@api` — the pretty-route registry (module hook + admin override tier). · `library/Tiger/Routing/Overrides.php` ### Data layer (base) diff --git a/ROUTING.md b/ROUTING.md index 60540e0..0bfa249 100644 --- a/ROUTING.md +++ b/ROUTING.md @@ -24,11 +24,12 @@ what everything else points at. Trailing `key/value` pairs fold into `getParam() A vanity URL is an **optional alias** — the canonical `//` path always works automagically via the default route, so you only add an alias for a nicer URL. **Never `$router->addRoute()` in a Bootstrap** (`_init*`) — routes are config, and hardcoding them scatters -precedence across bootstraps and makes you fight Zend's route stack. Two homes, by layer: +precedence across bootstraps and makes you fight Zend's route stack. Three homes, by layer: -- **Core / default-namespace aliases → `configs/routes.ini`.** ZF1-native `resources.router.routes.*`, - folded into the config cascade by `Tiger_Application::buildConfig`; the standard Router resource - applies them — no route code anywhere. Example (`/vibe` → `IndexController::vibeAction`): +- **Core / default-namespace aliases → `TIGER_CORE_PATH/configs/routes.ini` (or the app's + `configs/routes.ini`).** ZF1-native `resources.router.routes.*`, folded into the config cascade by + `Tiger_Application::buildConfig`; the standard Router resource applies them — no route code anywhere. + Example (`/vibe` → `IndexController::vibeAction`): ```ini [production] resources.router.routes.vibe.type = "Zend_Controller_Router_Route_Static" @@ -36,7 +37,19 @@ precedence across bootstraps and makes you fight Zend's route stack. Two homes, resources.router.routes.vibe.defaults.controller = "index" resources.router.routes.vibe.defaults.action = "vibe" ``` -- **Module aliases → a `Tiger_Routing_Overrides` declaration** (below), so one authority owns ordering. +- **Module routes → the module's own `configs/routes.ini`.** Same ZF1-native `resources.router.routes.*` + shape, declared *in the module*. `Tiger_Routing_ModuleRoutes` (called from + `Tiger_Application_Bootstrap::_initModuleRoutes`) discovers every **active** module's `routes.ini` and + registers them, **namespaced `__`** so a module can't stomp a core or peer route (app-dir + modules override a same-slug core-dir one). This is the general mechanism — it handles param routes + (`blog/:slug`) and **works under the reserved `/admin` prefix** (a native router route is matched + before dispatch, so it wins over the default MVC resolution, which `Tiger_Routing_Overrides` cannot do). + `modules/blog/configs/routes.ini` is the reference; `modules/agent` serves `/admin/settings/agent/skills` + this way. Declaration order within the file is preserved (ZF1 matches newest-first), so declare a + more-specific route *after* the catch-all it must shadow. +- **Module prefix→target aliases → a `Tiger_Routing_Overrides` declaration** (below), for the + "prefix maps to a target, the remainder is a `slug`" shape (e.g. a docs tree). Admin-reconfigurable via + config, but it **refuses reserved prefixes** (`/api`, `/auth`, `/admin`) — use `routes.ini` for those. A module declares its default alias from its Bootstrap: diff --git a/library/Tiger/Application/Bootstrap.php b/library/Tiger/Application/Bootstrap.php index b34fc67..7bbf96f 100644 --- a/library/Tiger/Application/Bootstrap.php +++ b/library/Tiger/Application/Bootstrap.php @@ -98,6 +98,39 @@ protected function _initAuthAliases() )); } + /** + * MODULE ROUTES: ingest every ACTIVE module's `configs/routes.ini` into the router — the missing + * per-type consumer for module route config (the mirror of how Tiger_Admin_Nav consumes + * navigation.ini, Tiger_Acl_Acl consumes acl.ini, etc.). A module declares pretty URLs declaratively + * as native `resources.router.routes.*`; no Bootstrap `addRoute` code. Routes are namespaced + * `__` so a module can't stomp a core or peer route (Tiger_Routing_ModuleRoutes). + * + * Runs AFTER the kernel routes (/api, /auth aliases) so those keep priority, and reads the active + * set from the DB (graceful: no DB yet → every module active). Registered here means a module's + * pretty URL works the instant it's dropped in — including under the reserved /admin prefix, which + * the routeShutdown Tiger_Routing_Overrides can't claim (a native router route is matched before + * dispatch, so it wins over the default MVC resolution). + */ + protected function _initModuleRoutes() + { + $this->bootstrap('frontController'); + try { $this->bootstrap('db'); } catch (Throwable $e) { /* fresh install / CLI — all active */ } + + $inactive = []; + try { + if (class_exists('Tiger_Model_Module')) { + $inactive = (new Tiger_Model_Module())->inactiveSlugs(); + } + } catch (Throwable $e) { /* no DB / no module table yet → everything active */ } + + Tiger_Routing_ModuleRoutes::apply( + $this->getResource('frontController')->getRouter(), + [TIGER_CORE_PATH . '/modules', APPLICATION_PATH . '/modules'], // app-dir last → app overrides core + $inactive, + APPLICATION_ENV + ); + } + /** * AUTHORIZATION: build the ACL (Tiger_Acl_Acl loads roles/resources/rules from * ini + DB) and register the unbypassable gate (Tiger_Controller_Plugin_ diff --git a/library/Tiger/Routing/ModuleRoutes.php b/library/Tiger/Routing/ModuleRoutes.php new file mode 100644 index 0000000..42fd948 --- /dev/null +++ b/library/Tiger/Routing/ModuleRoutes.php @@ -0,0 +1,88 @@ +addRoute()` from its Bootstrap (config-as-code). + * This closes that gap: drop the file, get the route. + * + * Collision safety: every route is namespaced `__`, so a module can never stomp a core route + * or another module's route by reusing a name. App-dir modules override a same-slug core-dir module + * (scanned last wins), matching the app-over-vendor precedence used everywhere else. Route *matching* + * still respects declaration order within a file (ZF1 checks routes newest-first), so a module orders its + * more-specific routes last to shadow its own catch-alls — exactly as `$router->addRoute()` did. + * + * @api + * @see Tiger_Admin_Nav the sibling discovery this mirrors (navigation.ini) + * @see Tiger_Application_Bootstrap::_initModuleRoutes the bootstrap seam that calls apply() + */ +class Tiger_Routing_ModuleRoutes +{ + /** + * Collect namespaced route definitions from active modules' `configs/routes.ini`. + * + * @param array $moduleDirs dirs to scan; each holds `/configs/routes.ini` + * @param array $inactiveSlugs module slugs to skip (deactivated → no routes) + * @param string|null $env the ini env section (APPLICATION_ENV); null = flat file + * @return array namespaced name (`__`) => a ZF1 route definition array + */ + public static function collect(array $moduleDirs, array $inactiveSlugs = [], $env = null) + { + $out = []; + foreach ($moduleDirs as $modsDir) { + foreach (glob($modsDir . '/*', GLOB_ONLYDIR) ?: [] as $moduleDir) { + $slug = basename($moduleDir); + if (in_array($slug, $inactiveSlugs, true)) { continue; } // deactivated → skipped (mirrors Nav) + $ini = $moduleDir . '/configs/routes.ini'; + if (!is_file($ini)) { continue; } + try { + $routes = self::_routesNode(new Zend_Config_Ini($ini, $env)); + if ($routes === null) { continue; } + foreach ($routes as $name => $def) { + if ($def instanceof Zend_Config) { $out[$slug . '__' . $name] = $def->toArray(); } + } + } catch (Throwable $e) { + error_log('Tiger_Routing_ModuleRoutes: failed to load ' . $ini . ' — ' . $e->getMessage()); + } + } + } + return $out; + } + + /** + * Collect + apply active modules' routes to a rewrite router via ZF1's native route factory. + * + * @param Zend_Controller_Router_Rewrite $router the front controller's router + * @param array $moduleDirs dirs to scan + * @param array $inactiveSlugs slugs to skip + * @param string|null $env the ini env section + * @return int the number of routes registered + */ + public static function apply($router, array $moduleDirs, array $inactiveSlugs = [], $env = null) + { + $routes = self::collect($moduleDirs, $inactiveSlugs, $env); + if (!$routes) { return 0; } + // addConfig() runs each entry through ZF1's route factory (honors `type`, defaulting to + // Zend_Controller_Router_Route) and preserves order → newest-first matching is retained. + $router->addConfig(new Zend_Config($routes)); + return count($routes); + } + + /** Navigate to `resources.router.routes` in a parsed routes.ini, or null if absent/malformed. */ + private static function _routesNode(Zend_Config $cfg) + { + $res = $cfg->get('resources'); + $router = ($res instanceof Zend_Config) ? $res->get('router') : null; + $routes = ($router instanceof Zend_Config) ? $router->get('routes') : null; + return ($routes instanceof Zend_Config) ? $routes : null; + } +} diff --git a/modules/blog/Bootstrap.php b/modules/blog/Bootstrap.php index 3726619..46bbdda 100644 --- a/modules/blog/Bootstrap.php +++ b/modules/blog/Bootstrap.php @@ -11,37 +11,11 @@ * * Extending Zend_Application_Module_Bootstrap gives the module its resource autoloader, * so Blog_Model_* (models/), Blog_Service_* (services/) and Blog_Form_* (forms/) load by - * convention; controllers load via the registered module dir; configs/acl.ini and - * languages/ are picked up by the core globs. + * convention; controllers load via the registered module dir; configs/acl.ini, configs/routes.ini + * (the /blog/* pretty URLs), and languages/ are picked up by the core globs. */ class Blog_Bootstrap extends Zend_Application_Module_Bootstrap { - /** - * Public front-end routes under /blog. The rewrite router checks routes newest-first, - * so ORDER here matters: the admin route (/blog/post → the authoring controller) is added - * LAST so it shadows the /blog/:slug article route for that one path. Everything else — - * /blog (index, via the default module route), /blog/, /blog/category|tag/, - * /blog/feed — resolves to Blog_IndexController. The words post/category/tag/feed are - * therefore reserved article slugs (enforced in Blog_Service_Post::save). - */ - protected function _initBlogRoutes() - { - $router = Zend_Controller_Front::getInstance()->getRouter(); - - $router->addRoute('blog_single', new Zend_Controller_Router_Route( - 'blog/:slug', ['module' => 'blog', 'controller' => 'index', 'action' => 'view'])); - $router->addRoute('blog_category', new Zend_Controller_Router_Route( - 'blog/category/:slug', ['module' => 'blog', 'controller' => 'index', 'action' => 'category'])); - $router->addRoute('blog_tag', new Zend_Controller_Router_Route( - 'blog/tag/:slug', ['module' => 'blog', 'controller' => 'index', 'action' => 'tag'])); - $router->addRoute('blog_feed', new Zend_Controller_Router_Route( - 'blog/feed', ['module' => 'blog', 'controller' => 'index', 'action' => 'feed'])); - - // Added last → checked first → /blog/post stays the admin list (not an article slug). - $router->addRoute('blog_admin', new Zend_Controller_Router_Route( - 'blog/post', ['module' => 'blog', 'controller' => 'post', 'action' => 'index'])); - } - /** Register the blog's "articles" search provider — the tap-in demo for Tiger_Search. */ protected function _initSearchProvider() { diff --git a/modules/blog/configs/routes.ini b/modules/blog/configs/routes.ini new file mode 100644 index 0000000..d385d1f --- /dev/null +++ b/modules/blog/configs/routes.ini @@ -0,0 +1,38 @@ +; SPDX-License-Identifier: BSD-3-Clause +; Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers. +; +; Public front-end routes under /blog (ingested by Tiger_Routing_ModuleRoutes — declarative, no Bootstrap +; code). The rewrite router checks routes newest-first, so ORDER matters: blogAdmin (/blog/post → the +; authoring controller) is declared LAST so it shadows the /blog/:slug article route for that one path. +; Everything else — /blog (index, default module route), /blog/, /blog/category|tag/, +; /blog/feed — resolves to Blog_IndexController. The words post/category/tag/feed are therefore reserved +; article slugs (enforced in Blog_Service_Post::save). Type defaults to Zend_Controller_Router_Route. +[production] +resources.router.routes.blogSingle.route = "blog/:slug" +resources.router.routes.blogSingle.defaults.module = "blog" +resources.router.routes.blogSingle.defaults.controller = "index" +resources.router.routes.blogSingle.defaults.action = "view" + +resources.router.routes.blogCategory.route = "blog/category/:slug" +resources.router.routes.blogCategory.defaults.module = "blog" +resources.router.routes.blogCategory.defaults.controller = "index" +resources.router.routes.blogCategory.defaults.action = "category" + +resources.router.routes.blogTag.route = "blog/tag/:slug" +resources.router.routes.blogTag.defaults.module = "blog" +resources.router.routes.blogTag.defaults.controller = "index" +resources.router.routes.blogTag.defaults.action = "tag" + +resources.router.routes.blogFeed.route = "blog/feed" +resources.router.routes.blogFeed.defaults.module = "blog" +resources.router.routes.blogFeed.defaults.controller = "index" +resources.router.routes.blogFeed.defaults.action = "feed" + +; LAST → checked first → /blog/post stays the admin list (not an article slug). +resources.router.routes.blogAdmin.route = "blog/post" +resources.router.routes.blogAdmin.defaults.module = "blog" +resources.router.routes.blogAdmin.defaults.controller = "post" +resources.router.routes.blogAdmin.defaults.action = "index" +[staging : production] +[testing : production] +[development : production] diff --git a/tests/Integration/Blog/BlogBootstrapTest.php b/tests/Integration/Blog/BlogBootstrapTest.php index b6907fc..9a6fcdc 100644 --- a/tests/Integration/Blog/BlogBootstrapTest.php +++ b/tests/Integration/Blog/BlogBootstrapTest.php @@ -10,18 +10,19 @@ use ReflectionProperty; use Tiger\Tests\Support\IntegrationTestCase; use Tiger_Model_Page; +use Tiger_Routing_ModuleRoutes; use Tiger_Search; use Tiger_Sitemap; -use Zend_Controller_Front; /** - * Blog_Bootstrap — the module's wiring: public /blog routes, the "articles" search-provider tap-in - * (the reference Tiger_Search demo), and the sitemap/llms provider. + * Blog_Bootstrap — the module's wiring: the "articles" search-provider tap-in (the reference + * Tiger_Search demo) and the sitemap/llms provider. (The public /blog routes are now declarative in + * configs/routes.ini, ingested by Tiger_Routing_ModuleRoutes — asserted here against that file.) * * Each _init* is invoked directly (the harness doesn't boot module bootstraps) and its effect is - * asserted against the real registries: the router has the blog routes, Tiger_Search has the - * articles provider whose closure resolves seeded articles, and Tiger_Sitemap::collect() runs the - * blog provider closure over published articles (excerpt/desc unpacked from page.meta). + * asserted against the real registries: Tiger_Search has the articles provider whose closure resolves + * seeded articles, and Tiger_Sitemap::collect() runs the blog provider closure over published articles + * (excerpt/desc unpacked from page.meta). */ #[CoversClass(\Blog_Bootstrap::class)] final class BlogBootstrapTest extends IntegrationTestCase @@ -63,14 +64,25 @@ private function seedArticle(array $overrides): string } #[Test] - public function it_registers_the_public_blog_routes(): void + public function it_ships_the_public_blog_routes_declaratively(): void { - $this->invoke('_initBlogRoutes'); - $router = Zend_Controller_Front::getInstance()->getRouter(); + // Routes now live in modules/blog/configs/routes.ini, ingested + namespaced by the core bootstrap. + $routes = Tiger_Routing_ModuleRoutes::collect([TIGER_CORE_PATH . '/modules'], [], APPLICATION_ENV); - foreach (['blog_single', 'blog_category', 'blog_tag', 'blog_feed', 'blog_admin'] as $name) { - $this->assertTrue($router->hasRoute($name), "route $name registered"); + foreach (['blogSingle', 'blogCategory', 'blogTag', 'blogFeed', 'blogAdmin'] as $name) { + $this->assertArrayHasKey('blog__' . $name, $routes, "blog route $name shipped in routes.ini"); } + $this->assertSame('blog/:slug', $routes['blog__blogSingle']['route']); + $this->assertSame('blog/post', $routes['blog__blogAdmin']['route'], 'the /blog/post admin list route'); + $this->assertSame('post', $routes['blog__blogAdmin']['defaults']['controller']); + + // blogAdmin is declared LAST so newest-first matching lets /blog/post shadow /blog/:slug. + $keys = array_keys($routes); + $this->assertGreaterThan( + array_search('blog__blogSingle', $keys, true), + array_search('blog__blogAdmin', $keys, true), + 'blogAdmin is ordered after blogSingle so it is matched first' + ); } #[Test] diff --git a/tests/Unit/Routing/ModuleRoutesTest.php b/tests/Unit/Routing/ModuleRoutesTest.php new file mode 100644 index 0000000..1d2998d --- /dev/null +++ b/tests/Unit/Routing/ModuleRoutesTest.php @@ -0,0 +1,105 @@ +__ (collision-proof), skip inactive modules, and let an app-dir module override a + * same-slug core-dir one. Network-/DB-free — fixture module dirs written to a temp tree. + */ +#[CoversClass(Tiger_Routing_ModuleRoutes::class)] +final class ModuleRoutesTest extends UnitTestCase +{ + private string $root = ''; + + protected function setUp(): void + { + parent::setUp(); + $this->root = sys_get_temp_dir() . '/tiger-mroutes-' . getmypid(); + $this->_seed('core/alpha', 'alphaHome', 'alpha', ['route' => 'alpha', 'controller' => 'index', 'action' => 'index']); + $this->_seed('core/beta', 'betaThing', 'beta', ['route' => 'beta/:id', 'controller' => 'index', 'action' => 'view']); + @mkdir($this->root . '/core/nocfg/configs', 0775, true); // a module with NO routes.ini → skipped + } + + protected function tearDown(): void + { + $this->_rrmdir($this->root); + parent::tearDown(); + } + + /** Write modules//configs/routes.ini with one namespaced route under $reldir. */ + private function _seed(string $reldir, string $name, string $slugForModule, array $def): void + { + $dir = $this->root . '/' . $reldir . '/configs'; + @mkdir($dir, 0775, true); + $ini = "[production]\n"; + $ini .= "resources.router.routes.$name.route = \"{$def['route']}\"\n"; + $ini .= "resources.router.routes.$name.defaults.module = \"$slugForModule\"\n"; + $ini .= "resources.router.routes.$name.defaults.controller = \"{$def['controller']}\"\n"; + $ini .= "resources.router.routes.$name.defaults.action = \"{$def['action']}\"\n"; + $ini .= "[testing : production]\n[development : production]\n[staging : production]\n"; + file_put_contents($dir . '/routes.ini', $ini); + } + + #[Test] + public function collects_and_namespaces_by_slug(): void + { + $routes = Tiger_Routing_ModuleRoutes::collect([$this->root . '/core'], [], 'production'); + $this->assertArrayHasKey('alpha__alphaHome', $routes, 'route is namespaced __'); + $this->assertArrayHasKey('beta__betaThing', $routes); + $this->assertSame('alpha', $routes['alpha__alphaHome']['route']); + $this->assertSame('view', $routes['beta__betaThing']['defaults']['action'], 'the full route def survives'); + } + + #[Test] + public function a_module_without_routes_ini_is_skipped(): void + { + $routes = Tiger_Routing_ModuleRoutes::collect([$this->root . '/core'], [], 'production'); + $this->assertArrayNotHasKey('nocfg__x', $routes); + $this->assertCount(2, $routes, 'only the two modules that ship routes.ini contribute'); + } + + #[Test] + public function inactive_modules_are_skipped(): void + { + $routes = Tiger_Routing_ModuleRoutes::collect([$this->root . '/core'], ['beta'], 'production'); + $this->assertArrayHasKey('alpha__alphaHome', $routes); + $this->assertArrayNotHasKey('beta__betaThing', $routes, 'a deactivated module contributes no routes'); + } + + #[Test] + public function app_dir_overrides_a_same_slug_core_module(): void + { + // A second dir (the "app" tree) with the SAME slug 'alpha' but a different route. + $appDir = $this->root . '/app/alpha/configs'; + @mkdir($appDir, 0775, true); + file_put_contents($appDir . '/routes.ini', + "[production]\nresources.router.routes.alphaHome.route = \"alpha-app\"\n" + . "resources.router.routes.alphaHome.defaults.module = \"alpha\"\n" + . "resources.router.routes.alphaHome.defaults.controller = \"index\"\n" + . "resources.router.routes.alphaHome.defaults.action = \"index\"\n" + . "[testing : production]\n[development : production]\n[staging : production]\n"); + + // core scanned first, app last → app wins the same namespaced key. + $routes = Tiger_Routing_ModuleRoutes::collect([$this->root . '/core', $this->root . '/app'], [], 'production'); + $this->assertSame('alpha-app', $routes['alpha__alphaHome']['route'], 'app-dir module overrides the core-dir one'); + } + + private function _rrmdir(string $dir): void + { + if (!is_dir($dir)) { return; } + foreach (scandir($dir) ?: [] as $f) { + if ($f === '.' || $f === '..') { continue; } + $p = $dir . '/' . $f; + is_dir($p) ? $this->_rrmdir($p) : @unlink($p); + } + @rmdir($dir); + } +}