From 6e85a341bf2714a215af06f54fdea729190819a2 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: 2025年8月28日 10:52:17 +0100 Subject: [PATCH 1/8] Allow guideline overriding From 59383f8ac409e2072b99b2f1bb78cccd16ebffae Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: 2025年8月28日 12:39:45 +0100 Subject: [PATCH 2/8] feat: enable guideline overriding --- README.md | 6 + src/Console/InstallCommand.php | 2 +- src/Install/GuidelineComposer.php | 131 ++++++++++++------ .../Feature/Install/GuidelineComposerTest.php | 51 ++++++- tests/Unit/Install/GuidelineComposerTest.php | 1 - .../.ai/guidelines/custom-rule.blade.php | 5 + .../.ai/guidelines/laravel/11/core.blade.php | 3 + .../.ai/guidelines/project-specific.blade.php | 5 + 8 files changed, 154 insertions(+), 50 deletions(-) delete mode 100644 tests/Unit/Install/GuidelineComposerTest.php create mode 100644 tests/fixtures/.ai/guidelines/custom-rule.blade.php create mode 100644 tests/fixtures/.ai/guidelines/laravel/11/core.blade.php create mode 100644 tests/fixtures/.ai/guidelines/project-specific.blade.php diff --git a/README.md b/README.md index 9d560de4..1667b883 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,12 @@ Laravel Boost includes AI guidelines for the following packages and frameworks. To augment Laravel Boost with your own custom AI guidelines, add `.blade.php` files to your application's `.ai/guidelines/*` directory. These files will automatically be included with Laravel Boost's guidelines when you run `boost:install`. +## Overriding Boost AI Guidelines + +You can override Boost's built-in AI guidelines with your own custom guidelines. Match your custom AI guideline path to an existing Boost guideline path, and Boost will install that instead. + +For example, to override Inertia React v2 Form Guidance you'd create `.ai/guidelines/inertia-react/2/forms.blade.php`. This file will now be included, instead of Boost's, when you run `boost:install`. + ## Manually Registering the Boost MCP Server Sometimes you may need to manually register the Laravel Boost MCP server with your editor of choice. You should register the MCP server using the following details: diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index 123f42ae..12cfa512 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -390,7 +390,7 @@ private function installGuidelines(): void $this->newLine(); $this->info(sprintf(' Adding %d guidelines to your selected agents', $guidelines->count())); - DisplayHelper::grid($guidelines->keys()->sort()->toArray(), $this->terminal->cols()); + DisplayHelper::grid($guidelines->map(fn ($guideline, $key) => $key.($guideline['custom'] ? '*' : ''))->sort()->toArray(), $this->terminal->cols()); $this->newLine(); usleep(750000); diff --git a/src/Install/GuidelineComposer.php b/src/Install/GuidelineComposer.php index cf1b0a67..648bb0f3 100644 --- a/src/Install/GuidelineComposer.php +++ b/src/Install/GuidelineComposer.php @@ -42,18 +42,24 @@ public function compose(): string return self::composeGuidelines($this->guidelines()); } + public function customGuidelinePath(string $path = ''): string + { + return base_path($this->userGuidelineDir.'/'.ltrim($path, '/')); + } + /** * Static method to compose guidelines from a collection. * Can be used without Laravel dependencies. * - * @param Collection $guidelines + * @param Collection $guidelines */ public static function composeGuidelines(Collection $guidelines): string { return str_replace("\n\n\n\n", "\n\n", trim($guidelines - ->filter(fn ($content) => ! empty(trim($content))) - ->map(fn ($content, $key) => "\n=== {$key} rules ===\n\n".trim($content)) - ->join("\n\n"))); + ->filter(fn ($guideline) => ! empty(trim($guideline['content']))) + ->map(fn ($guideline, $key) => "\n=== {$key} rules ===\n\n".trim($guideline['content'])) + ->join("\n\n")) + ); } /** @@ -86,7 +92,6 @@ protected function find(): Collection $guidelines = collect(); $guidelines->put('foundation', $this->guideline('foundation')); $guidelines->put('boost', $this->guideline('boost/core')); - $guidelines->put('php', $this->guideline('php/core')); // TODO: AI-48: Use composer target version, not PHP version. Production could be 8.1, but local is 8.4 @@ -119,49 +124,39 @@ protected function find(): Collection $guidelineDir.'/core', $this->guideline($guidelineDir.'/core') ); // Always add package core - - $guidelines->put( - $guidelineDir.'/v'.$package->majorVersion(), - $this->guidelinesDir($guidelineDir.'/'.$package->majorVersion()) - ); + $packageGuidelines = $this->guidelinesDir($guidelineDir.'/'.$package->majorVersion()); + foreach ($packageGuidelines as $guideline) { + $suffix = $guideline['name'] == 'core' ? '' : '/'.$guideline['name']; + $guidelines->put( + $guidelineDir.'/v'.$package->majorVersion().$suffix, + $guideline + ); + } } if ($this->config->enforceTests) { $guidelines->put('tests', $this->guideline('enforce-tests')); } - $userGuidelines = $this->guidelineFilesInDir(base_path($this->userGuidelineDir)); + $userGuidelines = $this->guidelinesDir($this->customGuidelinePath()); + $pathsUsed = $guidelines->pluck('path'); foreach ($userGuidelines as $guideline) { - $guidelineKey = '.ai/'.$guideline->getBasename('.blade.php'); - $guidelines->put($guidelineKey, $this->guideline($guideline->getPathname())); + if ($pathsUsed->contains($guideline['path'])) { + continue; // Don't include this twice if it's an override + } + $guidelines->put('.ai/'.$guideline['name'], $guideline); } return $guidelines - ->whereNotNull() - ->where(fn (string $guideline) => ! empty(trim($guideline))); + ->where(fn (array $guideline) => ! empty(trim($guideline['content']))); } /** - * @return Collection + * @param string $dirPath + * @return array */ - protected function guidelineFilesInDir(string $dirPath): Collection - { - if (! is_dir($dirPath)) { - $dirPath = str_replace('/', DIRECTORY_SEPARATOR, __DIR__.'/../../.ai/'.$dirPath); - } - - try { - return collect(iterator_to_array(Finder::create() - ->files() - ->in($dirPath) - ->name('*.blade.php'))); - } catch (DirectoryNotFoundException $e) { - return collect(); - } - } - - protected function guidelinesDir(string $dirPath): ?string + protected function guidelinesDir(string $dirPath): array { if (! is_dir($dirPath)) { $dirPath = str_replace('/', DIRECTORY_SEPARATOR, __DIR__.'/../../.ai/'.$dirPath); @@ -173,27 +168,26 @@ protected function guidelinesDir(string $dirPath): ?string ->in($dirPath) ->name('*.blade.php'); } catch (DirectoryNotFoundException $e) { - return null; + return []; } - $guidelines = ''; + $guidelines = []; foreach ($finder as $file) { - $guidelines .= $this->guideline($file->getRealPath()) ?? ''; - $guidelines .= PHP_EOL; + $guidelines[] = $this->guideline($file->getRealPath()); } return $guidelines; } - protected function guideline(string $path): ?string + /** + * @param string $path + * @return array{content: string, name: string, path: ?string, custom: bool} + */ + protected function guideline(string $path): array { - if (! file_exists($path)) { - $path = preg_replace('/\.blade\.php$/', '', $path); - $path = str_replace('/', DIRECTORY_SEPARATOR, __DIR__.'/../../.ai/'.$path.'.blade.php'); - } - - if (! file_exists($path)) { - return null; + $path = $this->guidelinePath($path); + if (is_null($path)) { + return ['content' => '', 'name' => '', 'path' => null, 'custom' => false]; } $content = file_get_contents($path); @@ -214,7 +208,12 @@ protected function guideline(string $path): ?string $rendered = str_replace(array_keys($this->storedSnippets), array_values($this->storedSnippets), $rendered); $this->storedSnippets = []; // Clear for next use - return trim($rendered); + return [ + 'content' => trim($rendered), + 'name' => str_replace('.blade.php', '', basename($path)), + 'path' => $path, + 'custom' => str_contains($path, $this->customGuidelinePath()), + ]; } private array $storedSnippets = []; @@ -233,4 +232,44 @@ private function processBoostSnippets(string $content): string return $placeholder; }, $content); } + + protected function prependPackageGuidelinePath(string $path): string + { + $path = preg_replace('/\.blade\.php$/', '', $path); + $path = str_replace('/', DIRECTORY_SEPARATOR, __DIR__.'/../../.ai/'.$path.'.blade.php'); + + return $path; + } + + protected function prependUserGuidelinePath(string $path): string + { + $path = preg_replace('/\.blade\.php$/', '', $path); + $path = str_replace('/', DIRECTORY_SEPARATOR, $this->customGuidelinePath($path.'.blade.php')); + + return $path; + } + + protected function guidelinePath(string $path): ?string + { + // Relative path, prepend our package path to it + if (! file_exists($path)) { + $path = $this->prependPackageGuidelinePath($path); + if (! file_exists($path)) { + return null; + } + } + + $path = realpath($path); + + // If this is a custom guideline, return it unchanged + if (str_contains($path, $this->customGuidelinePath())) { + return $path; + } + + // The path is not a custom guideline, check if the user has an override for this + $relativePath = ltrim(str_replace([realpath(__DIR__.'/../../'), '.ai/'], '', $path), '/'); + $customPath = $this->prependUserGuidelinePath($relativePath); + + return file_exists($customPath) ? $customPath : $path; + } } diff --git a/tests/Feature/Install/GuidelineComposerTest.php b/tests/Feature/Install/GuidelineComposerTest.php index f1e6846d..1c3b0dd2 100644 --- a/tests/Feature/Install/GuidelineComposerTest.php +++ b/tests/Feature/Install/GuidelineComposerTest.php @@ -206,9 +206,9 @@ expect($guidelines) ->toContain('=== inertia-react/core rules ===') - ->toContain('=== inertia-react/v2 rules ===') + ->toContain('=== inertia-react/v2/forms rules ===') ->toContain('=== inertia-vue/core rules ===') - ->toContain('=== inertia-vue/v2 rules ===') + ->toContain('=== inertia-vue/v2/forms rules ===') ->toContain('=== pest/core rules ==='); }); @@ -251,3 +251,50 @@ ->toContain('laravel/v11') ->toContain('pest/core'); }); + +test('includes user custom guidelines from .ai/guidelines directory', function () { + $packages = new PackageCollection([ + new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'), + ]); + + $this->roster->shouldReceive('packages')->andReturn($packages); + + $composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd])->makePartial(); + $composer + ->shouldReceive('customGuidelinePath') + ->andReturnUsing(fn ($path = '') => realpath(\Pest\testDirectory('fixtures/.ai/guidelines')).'/'.ltrim($path, '/')); + + expect($composer->compose()) + ->toContain('=== .ai/custom-rule rules ===') + ->toContain('=== .ai/project-specific rules ===') + ->toContain('This is a custom project-specific guideline') + ->toContain('Project-specific coding standards') + ->toContain('Database tables must use `snake_case` naming') + ->and($composer->used()) + ->toContain('.ai/custom-rule') + ->toContain('.ai/project-specific'); +}); + +test('non-empty custom guidelines override Boost guidelines', function () { + $packages = new PackageCollection([ + new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'), + ]); + + $this->roster->shouldReceive('packages')->andReturn($packages); + + $composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd])->makePartial(); + $composer + ->shouldReceive('customGuidelinePath') + ->andReturnUsing(fn ($path = '') => realpath(\Pest\testDirectory('fixtures/.ai/guidelines')).'/'.ltrim($path, '/')); + + $guidelines = $composer->compose(); + $overrideStringCount = substr_count($guidelines, 'Thanks though, appreciate you'); + + expect($overrideStringCount)->toBe(1) + ->and($guidelines) + ->toContain('Thanks though, appreciate you') // From user guidelines + ->not->toContain('## Laravel 11') // Heading from Boost's L11/core guideline + ->and($composer->used()) + ->toContain('.ai/custom-rule') + ->toContain('.ai/project-specific'); +}); diff --git a/tests/Unit/Install/GuidelineComposerTest.php b/tests/Unit/Install/GuidelineComposerTest.php deleted file mode 100644 index b3d9bbc7..00000000 --- a/tests/Unit/Install/GuidelineComposerTest.php +++ /dev/null @@ -1 +0,0 @@ -package('laravel')` helper when available From b7df023369fa4b291f7a44a7c359783890e0666c Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: 2025年8月28日 12:43:58 +0100 Subject: [PATCH 3/8] refactor: simplify guideline building from dir --- src/Install/GuidelineComposer.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Install/GuidelineComposer.php b/src/Install/GuidelineComposer.php index 648bb0f3..d0185999 100644 --- a/src/Install/GuidelineComposer.php +++ b/src/Install/GuidelineComposer.php @@ -171,12 +171,7 @@ protected function guidelinesDir(string $dirPath): array return []; } - $guidelines = []; - foreach ($finder as $file) { - $guidelines[] = $this->guideline($file->getRealPath()); - } - - return $guidelines; + return array_map(fn ($file) => $this->guideline($file->getRealPath()), iterator_to_array($finder)); } /** From 4cfdc89d353821fc4baacad84f6041821fd28449 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: 2025年8月28日 12:49:50 +0100 Subject: [PATCH 4/8] refactor: phpstan for collection of guideline arrays --- src/Install/GuidelineComposer.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Install/GuidelineComposer.php b/src/Install/GuidelineComposer.php index d0185999..cb8a5513 100644 --- a/src/Install/GuidelineComposer.php +++ b/src/Install/GuidelineComposer.php @@ -14,7 +14,7 @@ class GuidelineComposer { protected string $userGuidelineDir = '.ai/guidelines'; - /** @var Collection */ + /** @var Collection */ protected Collection $guidelines; protected GuidelineConfig $config; @@ -71,7 +71,7 @@ public function used(): array } /** - * @return Collection + * @return Collection */ public function guidelines(): Collection { @@ -85,7 +85,7 @@ public function guidelines(): Collection /** * Key is the 'guideline key' and value is the rendered blade. * - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection */ protected function find(): Collection { From 9a7eb9b3ef38aa3d583102f77f188caf37f364b3 Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: 2025年8月28日 12:57:19 +0100 Subject: [PATCH 5/8] refactor: phpstan for displayhelper::grid --- src/Console/InstallCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index 12cfa512..413669c5 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -390,7 +390,7 @@ private function installGuidelines(): void $this->newLine(); $this->info(sprintf(' Adding %d guidelines to your selected agents', $guidelines->count())); - DisplayHelper::grid($guidelines->map(fn ($guideline, $key) => $key.($guideline['custom'] ? '*' : ''))->sort()->toArray(), $this->terminal->cols()); + DisplayHelper::grid($guidelines->map(fn ($guideline, string $key) => $key.($guideline['custom'] ? '*' : ''))->values()->sort()->toArray(), $this->terminal->cols()); $this->newLine(); usleep(750000); From 934a62eb09870709254fc05a54c3b36d110cce0c Mon Sep 17 00:00:00 2001 From: Ashley Hindle Date: 2025年8月28日 12:59:00 +0100 Subject: [PATCH 6/8] refactor: displayhelper::grid usage --- src/Console/InstallCommand.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index 413669c5..c84aa572 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -390,7 +390,14 @@ private function installGuidelines(): void $this->newLine(); $this->info(sprintf(' Adding %d guidelines to your selected agents', $guidelines->count())); - DisplayHelper::grid($guidelines->map(fn ($guideline, string $key) => $key.($guideline['custom'] ? '*' : ''))->values()->sort()->toArray(), $this->terminal->cols()); + DisplayHelper::grid( + $guidelines + ->map(fn ($guideline, string $key) => $key.($guideline['custom'] ? '*' : '')) + ->values() + ->sort() + ->toArray(), + $this->terminal->cols() + ); $this->newLine(); usleep(750000); From 0391d6f750d4592f3791ded33e68576c6118c5c8 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: 2025年8月29日 16:11:20 +0530 Subject: [PATCH 7/8] add new line to fixtures --- tests/fixtures/.ai/guidelines/custom-rule.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/.ai/guidelines/custom-rule.blade.php b/tests/fixtures/.ai/guidelines/custom-rule.blade.php index 9fbc1116..49fec7d0 100644 --- a/tests/fixtures/.ai/guidelines/custom-rule.blade.php +++ b/tests/fixtures/.ai/guidelines/custom-rule.blade.php @@ -2,4 +2,4 @@ Use the following conventions: - Always prefix custom classes with `Project` -- Use camelCase for method names \ No newline at end of file +- Use camelCase for method names From d6ed50a8d1a694da7817d66457692090f5d832e0 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Tue, 2 Sep 2025 10:47:34 -0500 Subject: [PATCH 8/8] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1667b883..09f38ad3 100644 --- a/README.md +++ b/README.md @@ -97,11 +97,11 @@ Laravel Boost includes AI guidelines for the following packages and frameworks. To augment Laravel Boost with your own custom AI guidelines, add `.blade.php` files to your application's `.ai/guidelines/*` directory. These files will automatically be included with Laravel Boost's guidelines when you run `boost:install`. -## Overriding Boost AI Guidelines +### Overriding Boost AI Guidelines -You can override Boost's built-in AI guidelines with your own custom guidelines. Match your custom AI guideline path to an existing Boost guideline path, and Boost will install that instead. +You can override Boost's built-in AI guidelines by creating your own custom guidelines with matching file paths. When you create a custom guideline that matches an existing Boost guideline path, Boost will use your custom version instead of the built-in one. -For example, to override Inertia React v2 Form Guidance you'd create `.ai/guidelines/inertia-react/2/forms.blade.php`. This file will now be included, instead of Boost's, when you run `boost:install`. +For example, to override Boost's "Inertia React v2 Form Guidance" guidelines, create a file at `.ai/guidelines/inertia-react/2/forms.blade.php`. When you run `boost:install`, Boost will include your custom guideline instead of the default one. ## Manually Registering the Boost MCP Server

AltStyle によって変換されたページ (->オリジナル) /