From 2f287b6afd8944415de3f943fa10fb42cb41fc36 Mon Sep 17 00:00:00 2001 From: Mohammad Oveisi Date: Tue, 17 Feb 2026 20:34:55 +0000 Subject: [PATCH 1/5] feat: support discovery for modular directory structures --- src/Install/GuidelineAssist.php | 93 ++++++-- .../Feature/Install/GuidelineComposerTest.php | 1 + tests/Unit/Install/GuidelineAssistTest.php | 209 +++++++++++++++++- 3 files changed, 284 insertions(+), 19 deletions(-) diff --git a/src/Install/GuidelineAssist.php b/src/Install/GuidelineAssist.php index 8c2de417..c6876c52 100644 --- a/src/Install/GuidelineAssist.php +++ b/src/Install/GuidelineAssist.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Laravel\Boost\Install\Assists\Inertia; +use Laravel\Roster\Enums\Approaches; use Laravel\Roster\Enums\NodePackageManager; use Laravel\Roster\Enums\Packages; use Laravel\Roster\Roster; @@ -67,36 +68,43 @@ public function enums(): array protected function discover(callable $cb): array { $classes = []; - $appPath = app_path(); - - if (! is_dir($appPath)) { - return ['app-path-isnt-a-directory' => $appPath]; + $paths = $this->roster->uses(Approaches::MODULAR) + ? array_filter([ + base_path('modules'), + base_path('Modules'), + base_path('app-modules'), + app_path(), + ], is_dir(...)) + : [app_path()]; + $cacheKey = md5(implode('|', $paths)); + + if ($paths === []) { + return []; } - if (self::$classes === []) { + if (! isset(self::$classes[$cacheKey])) { + self::$classes[$cacheKey] = []; $finder = Finder::create() - ->in($appPath) + ->in($paths) ->files() ->name('/[A-Z].*\.php$/'); foreach ($finder as $file) { - $relativePath = $file->getRelativePathname(); - $namespace = app()->getNamespace(); - $className = $namespace.str_replace( - ['/', '.php'], - ['\\', ''], - $relativePath - ); - try { - $path = $appPath.DIRECTORY_SEPARATOR.$relativePath; + $path = $file->getRealPath(); + + if (! $path) { + continue; + } if (! $this->fileHasClassLike($path)) { continue; } - if (class_exists($className, false)) { - self::$classes[$className] = $path; + $className = $this->classNameFromFile($path); + + if ($className && class_exists($className)) { + self::$classes[$cacheKey][$className] = $path; } } catch (Throwable) { // Ignore exceptions and errors from class loading/reflection @@ -104,7 +112,7 @@ protected function discover(callable $cb): array } } - foreach (self::$classes as $className => $path) { + foreach (self::$classes[$cacheKey] as $className => $path) { if ($cb(new ReflectionClass($className))) { $classes[$className] = $path; } @@ -113,6 +121,55 @@ protected function discover(callable $cb): array return $classes; } + protected function classNameFromFile(string $path): ?string + { + $code = file_get_contents($path); + + if ($code === false) { + return null; + } + + $namespace = null; + $class = null; + + $tokens = token_get_all($code); + $count = count($tokens); + + for ($i = 0; $i < $count; $i++) { + if (is_array($tokens[$i]) && $tokens[$i][0] === T_NAMESPACE) { + $namespace = ''; + + for ($j = $i + 1; $j < $count; $j++) { + if (is_array($tokens[$j]) && in_array($tokens[$j][0], [T_STRING, T_NS_SEPARATOR, T_NAME_QUALIFIED], true)) { + $namespace .= $tokens[$j][1]; + + continue; + } + + if ($tokens[$j] === ';' || $tokens[$j] === '{') { + break; + } + } + } + + if (is_array($tokens[$i]) && in_array($tokens[$i][0], [T_CLASS, T_ENUM], true)) { + for ($j = $i + 1; $j < $count; $j++) { + if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) { + $class = $tokens[$j][1]; + + break 2; + } + } + } + } + + if (! $class) { + return null; + } + + return $namespace ? "{$namespace}\\{$class}" : $class; + } + public function fileHasClassLike(string $path): bool { static $cache = []; diff --git a/tests/Feature/Install/GuidelineComposerTest.php b/tests/Feature/Install/GuidelineComposerTest.php index f56aaf57..9981e0f4 100644 --- a/tests/Feature/Install/GuidelineComposerTest.php +++ b/tests/Feature/Install/GuidelineComposerTest.php @@ -24,6 +24,7 @@ $this->herd->shouldReceive('isInstalled')->andReturn(false)->byDefault(); $this->app->instance(Roster::class, $this->roster); + $this->roster->shouldReceive('uses')->andReturn(false)->byDefault(); $this->composer = new GuidelineComposer($this->roster, $this->herd); }); diff --git a/tests/Unit/Install/GuidelineAssistTest.php b/tests/Unit/Install/GuidelineAssistTest.php index 2f420b5e..2cbd8069 100644 --- a/tests/Unit/Install/GuidelineAssistTest.php +++ b/tests/Unit/Install/GuidelineAssistTest.php @@ -11,7 +11,7 @@ $this->roster = Mockery::mock(Roster::class); $this->roster->shouldReceive('nodePackageManager')->andReturn(null); $this->roster->shouldReceive('usesVersion')->andReturn(false); - + $this->roster->shouldReceive('uses')->andReturn(false)->byDefault(); $this->config = new GuidelineConfig; }); @@ -163,3 +163,210 @@ expect($assist->hasSkillsEnabled())->toBeTrue(); }); + +test('discovers models outside the app directory', function (): void { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + $modulesDir = $tempDir.'/modules/Blog/Models'; + mkdir($modulesDir, 0777, true); + + $modelPath = $modulesDir.'/Post.php'; + + file_put_contents($modelPath, <<<'PHP' +app->setBasePath($tempDir); + mkdir($tempDir.'/app', 0777, true); + $this->app->useAppPath($tempDir.'/app'); + + $this->roster + ->shouldReceive('uses') + ->with(\Laravel\Roster\Enums\Approaches::MODULAR) + ->andReturn(true); + + require_once $modelPath; + + try { + $assist = new GuidelineAssist($this->roster, new GuidelineConfig); + + expect($assist->models())->toHaveKey('Modules\Blog\Models\Post'); + } finally { + unlink($modelPath); + rmdir($modulesDir); + rmdir(dirname($modulesDir)); + rmdir(dirname($modulesDir, 2)); + rmdir($tempDir.'/app'); + rmdir($tempDir); + } +}); + +test('discovers models in Modules directory (capital M)', function (): void { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + $modulesDir = $tempDir.'/Modules/Blog/Models'; + mkdir($modulesDir, 0777, true); + + $modelPath = $modulesDir.'/Article.php'; + + file_put_contents($modelPath, <<<'PHP' +app->setBasePath($tempDir); + mkdir($tempDir.'/app', 0777, true); + $this->app->useAppPath($tempDir.'/app'); + + $this->roster + ->shouldReceive('uses') + ->with(\Laravel\Roster\Enums\Approaches::MODULAR) + ->andReturn(true); + + require_once $modelPath; + + try { + $assist = new GuidelineAssist($this->roster, new GuidelineConfig); + + expect($assist->models())->toHaveKey('Modules\Blog\Models\Article'); + } finally { + unlink($modelPath); + rmdir($modulesDir); + rmdir(dirname($modulesDir)); + rmdir(dirname($modulesDir, 2)); + rmdir($tempDir.'/app'); + rmdir($tempDir); + } +}); + +test('discovers models in app-modules directory', function (): void { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + $modulesDir = $tempDir.'/app-modules/Blog/Models'; + mkdir($modulesDir, 0777, true); + + $modelPath = $modulesDir.'/Comment.php'; + + file_put_contents($modelPath, <<<'PHP' +app->setBasePath($tempDir); + mkdir($tempDir.'/app', 0777, true); + $this->app->useAppPath($tempDir.'/app'); + + $this->roster + ->shouldReceive('uses') + ->with(\Laravel\Roster\Enums\Approaches::MODULAR) + ->andReturn(true); + + require_once $modelPath; + + try { + $assist = new GuidelineAssist($this->roster, new GuidelineConfig); + + expect($assist->models())->toHaveKey('AppModules\Blog\Models\Comment'); + } finally { + unlink($modelPath); + rmdir($modulesDir); + rmdir(dirname($modulesDir)); + rmdir(dirname($modulesDir, 2)); + rmdir($tempDir.'/app'); + rmdir($tempDir); + } +}); + +test('discovers models in app directory when not modular', function (): void { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + $appModelsDir = $tempDir.'/app/Models'; + mkdir($appModelsDir, 0777, true); + + $modelPath = $appModelsDir.'/User.php'; + + file_put_contents($modelPath, <<<'PHP' +app->setBasePath($tempDir); + $this->app->useAppPath($tempDir.'/app'); + + $this->roster + ->shouldReceive('uses') + ->with(\Laravel\Roster\Enums\Approaches::MODULAR) + ->andReturn(false); + + require_once $modelPath; + + try { + $assist = new GuidelineAssist($this->roster, new GuidelineConfig); + + expect($assist->models())->toHaveKey('App\Models\User'); + } finally { + unlink($modelPath); + rmdir($appModelsDir); + rmdir($tempDir.'/app'); + rmdir($tempDir); + } +}); + +test('does not discover models outside app directory when not modular', function (): void { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + $modulesDir = $tempDir.'/modules/Blog/Models'; + mkdir($modulesDir, 0777, true); + mkdir($tempDir.'/app', 0777, true); + + $modelPath = $modulesDir.'/Tag.php'; + + file_put_contents($modelPath, <<<'PHP' +app->setBasePath($tempDir); + $this->app->useAppPath($tempDir.'/app'); + + $this->roster + ->shouldReceive('uses') + ->with(\Laravel\Roster\Enums\Approaches::MODULAR) + ->andReturn(false); + + require_once $modelPath; + + try { + $assist = new GuidelineAssist($this->roster, new GuidelineConfig); + + expect($assist->models())->not->toHaveKey('Modules\Blog\Models\Tag'); + } finally { + unlink($modelPath); + rmdir($modulesDir); + rmdir(dirname($modulesDir)); + rmdir(dirname($modulesDir, 2)); + rmdir($tempDir.'/app'); + rmdir($tempDir); + } +}); From 114294952d864178b0a2aa82d002c117bfca471d Mon Sep 17 00:00:00 2001 From: Mohammad Oveisi Date: Fri, 20 Feb 2026 15:05:37 +0000 Subject: [PATCH 2/5] fix tests --- tests/Feature/Install/SkillComposerTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Feature/Install/SkillComposerTest.php b/tests/Feature/Install/SkillComposerTest.php index 5c5a18f9..b216b3f6 100644 --- a/tests/Feature/Install/SkillComposerTest.php +++ b/tests/Feature/Install/SkillComposerTest.php @@ -16,6 +16,7 @@ $this->roster = Mockery::mock(Roster::class); $this->roster->shouldReceive('nodePackageManager')->andReturn(NodePackageManager::NPM); $this->roster->shouldReceive('usesVersion')->andReturn(false); + $this->roster->shouldReceive('uses')->andReturn(false); $this->app->instance(Roster::class, $this->roster); }); From 91db8a4e861314c4dd170ac9445afa53434be355 Mon Sep 17 00:00:00 2001 From: soleinjast <117115652+soleinjast@users.noreply.github.com> Date: Mon, 16 Mar 2026 13:33:56 +0000 Subject: [PATCH 3/5] Fix code styling --- tests/Unit/Install/GuidelineAssistTest.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/Unit/Install/GuidelineAssistTest.php b/tests/Unit/Install/GuidelineAssistTest.php index f48657d6..40523d01 100644 --- a/tests/Unit/Install/GuidelineAssistTest.php +++ b/tests/Unit/Install/GuidelineAssistTest.php @@ -5,6 +5,7 @@ use Laravel\Boost\Install\GuidelineAssist; use Laravel\Boost\Install\GuidelineConfig; use Laravel\Boost\Install\Sail; +use Laravel\Roster\Enums\Approaches; use Laravel\Roster\Roster; beforeEach(function (): void { @@ -227,7 +228,7 @@ class Post extends Model {} $this->roster ->shouldReceive('uses') - ->with(\Laravel\Roster\Enums\Approaches::MODULAR) + ->with(Approaches::MODULAR) ->andReturn(true); require_once $modelPath; @@ -269,7 +270,7 @@ class Article extends Model {} $this->roster ->shouldReceive('uses') - ->with(\Laravel\Roster\Enums\Approaches::MODULAR) + ->with(Approaches::MODULAR) ->andReturn(true); require_once $modelPath; @@ -311,7 +312,7 @@ class Comment extends Model {} $this->roster ->shouldReceive('uses') - ->with(\Laravel\Roster\Enums\Approaches::MODULAR) + ->with(Approaches::MODULAR) ->andReturn(true); require_once $modelPath; @@ -352,7 +353,7 @@ class User extends Model {} $this->roster ->shouldReceive('uses') - ->with(\Laravel\Roster\Enums\Approaches::MODULAR) + ->with(Approaches::MODULAR) ->andReturn(false); require_once $modelPath; @@ -392,7 +393,7 @@ class Tag extends Model {} $this->roster ->shouldReceive('uses') - ->with(\Laravel\Roster\Enums\Approaches::MODULAR) + ->with(Approaches::MODULAR) ->andReturn(false); require_once $modelPath; From b7d3d631ee212330b2c922c6dc1c2dc13a615e0f Mon Sep 17 00:00:00 2001 From: Mohammad Oveisi Date: Mon, 16 Mar 2026 13:55:16 +0000 Subject: [PATCH 4/5] tiny ref on GuidelineAssistTest --- tests/Unit/Install/GuidelineAssistTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Install/GuidelineAssistTest.php b/tests/Unit/Install/GuidelineAssistTest.php index 40523d01..92681a49 100644 --- a/tests/Unit/Install/GuidelineAssistTest.php +++ b/tests/Unit/Install/GuidelineAssistTest.php @@ -12,7 +12,7 @@ $this->roster = Mockery::mock(Roster::class); $this->roster->shouldReceive('nodePackageManager')->andReturn(null); $this->roster->shouldReceive('usesVersion')->andReturn(false); - + $this->roster->shouldReceive('uses')->withAnyArgs()->andReturn(false)->byDefault(); $this->config = new GuidelineConfig; }); From 36c446af2df21618694a394a53423605d7ba808a Mon Sep 17 00:00:00 2001 From: Mohammad Oveisi Date: Mon, 16 Mar 2026 14:03:33 +0000 Subject: [PATCH 5/5] fix(boost): allow Mockery uses() expectations to be overridden with byDefault --- tests/Feature/Mcp/Prompts/UpgradeInertiav3Test.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Feature/Mcp/Prompts/UpgradeInertiav3Test.php b/tests/Feature/Mcp/Prompts/UpgradeInertiav3Test.php index 34d4b7ce..1be2ac12 100644 --- a/tests/Feature/Mcp/Prompts/UpgradeInertiav3Test.php +++ b/tests/Feature/Mcp/Prompts/UpgradeInertiav3Test.php @@ -14,6 +14,7 @@ function mockRosterWithFrameworks(bool $react = false, bool $vue = false, bool $svelte = false): Roster { $roster = Mockery::mock(Roster::class); + $roster->shouldReceive('uses')->withAnyArgs()->andReturn(false)->byDefault(); $roster->shouldReceive('uses')->with(Packages::INERTIA_REACT)->andReturn($react); $roster->shouldReceive('uses')->with(Packages::INERTIA_VUE)->andReturn($vue); $roster->shouldReceive('uses')->with(Packages::INERTIA_SVELTE)->andReturn($svelte);