Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Allow guideline overriding #219

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
ashleyhindle merged 9 commits into main from ai-96-allow-guideline-exclusion-overriding
Sep 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 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

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:
Expand Down
9 changes: 8 additions & 1 deletion src/Console/InstallCommand.php
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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->keys()->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);

Expand Down
140 changes: 87 additions & 53 deletions src/Install/GuidelineComposer.php
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class GuidelineComposer
{
protected string $userGuidelineDir = '.ai/guidelines';

/** @var Collection<string, string> */
/** @var Collection<string, array> */
protected Collection $guidelines;

protected GuidelineConfig $config;
Expand Down Expand Up @@ -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<string, string> $guidelines
* @param Collection<string, array{content: string, name: string, path: ?string, custom: bool}> $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"))
);
}

/**
Expand All @@ -65,7 +71,7 @@ public function used(): array
}

/**
* @return Collection<string, string>
* @return Collection<string, array>
*/
public function guidelines(): Collection
{
Expand All @@ -79,14 +85,13 @@ public function guidelines(): Collection
/**
* Key is the 'guideline key' and value is the rendered blade.
*
* @return \Illuminate\Support\Collection<string, string>
* @return \Illuminate\Support\Collection<string, array>
*/
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
Expand Down Expand Up @@ -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<string, \Symfony\Component\Finder\SplFileInfo>
* @param string $dirPath
* @return array<array{content: string, name: string, path: ?string, custom: bool}>
*/
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);
Expand All @@ -173,27 +168,21 @@ protected function guidelinesDir(string $dirPath): ?string
->in($dirPath)
->name('*.blade.php');
} catch (DirectoryNotFoundException $e) {
return null;
return [];
}

$guidelines = '';
foreach ($finder as $file) {
$guidelines .= $this->guideline($file->getRealPath()) ?? '';
$guidelines .= PHP_EOL;
}

return $guidelines;
return array_map(fn ($file) => $this->guideline($file->getRealPath()), iterator_to_array($finder));
}

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);
Expand All @@ -214,7 +203,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 = [];
Expand All @@ -233,4 +227,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;
}
}
51 changes: 49 additions & 2 deletions tests/Feature/Install/GuidelineComposerTest.php
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===');
});

Expand Down Expand Up @@ -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');
});
1 change: 0 additions & 1 deletion tests/Unit/Install/GuidelineComposerTest.php
View file Open in desktop

This file was deleted.

5 changes: 5 additions & 0 deletions tests/fixtures/.ai/guidelines/custom-rule.blade.php
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
This is a custom project-specific guideline.

Use the following conventions:
- Always prefix custom classes with `Project`
- Use camelCase for method names
3 changes: 3 additions & 0 deletions tests/fixtures/.ai/guidelines/laravel/11/core.blade.php
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
I don't want your guidelines, I've got my own, and they're great.

Thanks though, appreciate you!
5 changes: 5 additions & 0 deletions tests/fixtures/.ai/guidelines/project-specific.blade.php
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Project-specific coding standards:

- Database tables must use `snake_case` naming
- All controllers should extend `BaseController`
- Use the `@assist->package('laravel')` helper when available

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