Skip to content

Commit 149986e

Browse files
authored
Merge pull request #219 from laravel/ai-96-allow-guideline-exclusion-overriding
Allow guideline overriding
2 parents ada6303 + d6ed50a commit 149986e

File tree

8 files changed

+163
-57
lines changed

8 files changed

+163
-57
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ Laravel Boost includes AI guidelines for the following packages and frameworks.
9797

9898
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`.
9999

100+
### Overriding Boost AI Guidelines
101+
102+
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.
103+
104+
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.
105+
100106
## Manually Registering the Boost MCP Server
101107

102108
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:

src/Console/InstallCommand.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,14 @@ private function installGuidelines(): void
390390

391391
$this->newLine();
392392
$this->info(sprintf(' Adding %d guidelines to your selected agents', $guidelines->count()));
393-
DisplayHelper::grid($guidelines->keys()->sort()->toArray(), $this->terminal->cols());
393+
DisplayHelper::grid(
394+
$guidelines
395+
->map(fn ($guideline, string $key) => $key.($guideline['custom'] ? '*' : ''))
396+
->values()
397+
->sort()
398+
->toArray(),
399+
$this->terminal->cols()
400+
);
394401
$this->newLine();
395402
usleep(750000);
396403

src/Install/GuidelineComposer.php

Lines changed: 87 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class GuidelineComposer
1414
{
1515
protected string $userGuidelineDir = '.ai/guidelines';
1616

17-
/** @var Collection<string, string> */
17+
/** @var Collection<string, array> */
1818
protected Collection $guidelines;
1919

2020
protected GuidelineConfig $config;
@@ -42,18 +42,24 @@ public function compose(): string
4242
return self::composeGuidelines($this->guidelines());
4343
}
4444

45+
public function customGuidelinePath(string $path = ''): string
46+
{
47+
return base_path($this->userGuidelineDir.'/'.ltrim($path, '/'));
48+
}
49+
4550
/**
4651
* Static method to compose guidelines from a collection.
4752
* Can be used without Laravel dependencies.
4853
*
49-
* @param Collection<string, string> $guidelines
54+
* @param Collection<string, array{content: string, name: string, path: ?string, custom: bool}> $guidelines
5055
*/
5156
public static function composeGuidelines(Collection $guidelines): string
5257
{
5358
return str_replace("\n\n\n\n", "\n\n", trim($guidelines
54-
->filter(fn ($content) => ! empty(trim($content)))
55-
->map(fn ($content, $key) => "\n=== {$key} rules ===\n\n".trim($content))
56-
->join("\n\n")));
59+
->filter(fn ($guideline) => ! empty(trim($guideline['content'])))
60+
->map(fn ($guideline, $key) => "\n=== {$key} rules ===\n\n".trim($guideline['content']))
61+
->join("\n\n"))
62+
);
5763
}
5864

5965
/**
@@ -65,7 +71,7 @@ public function used(): array
6571
}
6672

6773
/**
68-
* @return Collection<string, string>
74+
* @return Collection<string, array>
6975
*/
7076
public function guidelines(): Collection
7177
{
@@ -79,14 +85,13 @@ public function guidelines(): Collection
7985
/**
8086
* Key is the 'guideline key' and value is the rendered blade.
8187
*
82-
* @return \Illuminate\Support\Collection<string, string>
88+
* @return \Illuminate\Support\Collection<string, array>
8389
*/
8490
protected function find(): Collection
8591
{
8692
$guidelines = collect();
8793
$guidelines->put('foundation', $this->guideline('foundation'));
8894
$guidelines->put('boost', $this->guideline('boost/core'));
89-
9095
$guidelines->put('php', $this->guideline('php/core'));
9196

9297
// 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
119124
$guidelineDir.'/core',
120125
$this->guideline($guidelineDir.'/core')
121126
); // Always add package core
122-
123-
$guidelines->put(
124-
$guidelineDir.'/v'.$package->majorVersion(),
125-
$this->guidelinesDir($guidelineDir.'/'.$package->majorVersion())
126-
);
127+
$packageGuidelines = $this->guidelinesDir($guidelineDir.'/'.$package->majorVersion());
128+
foreach ($packageGuidelines as $guideline) {
129+
$suffix = $guideline['name'] == 'core' ? '' : '/'.$guideline['name'];
130+
$guidelines->put(
131+
$guidelineDir.'/v'.$package->majorVersion().$suffix,
132+
$guideline
133+
);
134+
}
127135
}
128136

129137
if ($this->config->enforceTests) {
130138
$guidelines->put('tests', $this->guideline('enforce-tests'));
131139
}
132140

133-
$userGuidelines = $this->guidelineFilesInDir(base_path($this->userGuidelineDir));
141+
$userGuidelines = $this->guidelinesDir($this->customGuidelinePath());
142+
$pathsUsed = $guidelines->pluck('path');
134143

135144
foreach ($userGuidelines as $guideline) {
136-
$guidelineKey = '.ai/'.$guideline->getBasename('.blade.php');
137-
$guidelines->put($guidelineKey, $this->guideline($guideline->getPathname()));
145+
if ($pathsUsed->contains($guideline['path'])) {
146+
continue; // Don't include this twice if it's an override
147+
}
148+
$guidelines->put('.ai/'.$guideline['name'], $guideline);
138149
}
139150

140151
return $guidelines
141-
->whereNotNull()
142-
->where(fn (string $guideline) => ! empty(trim($guideline)));
152+
->where(fn (array $guideline) => ! empty(trim($guideline['content'])));
143153
}
144154

145155
/**
146-
* @return Collection<string, \Symfony\Component\Finder\SplFileInfo>
156+
* @param string $dirPath
157+
* @return array<array{content: string, name: string, path: ?string, custom: bool}>
147158
*/
148-
protected function guidelineFilesInDir(string $dirPath): Collection
149-
{
150-
if (! is_dir($dirPath)) {
151-
$dirPath = str_replace('/', DIRECTORY_SEPARATOR, __DIR__.'/../../.ai/'.$dirPath);
152-
}
153-
154-
try {
155-
return collect(iterator_to_array(Finder::create()
156-
->files()
157-
->in($dirPath)
158-
->name('*.blade.php')));
159-
} catch (DirectoryNotFoundException $e) {
160-
return collect();
161-
}
162-
}
163-
164-
protected function guidelinesDir(string $dirPath): ?string
159+
protected function guidelinesDir(string $dirPath): array
165160
{
166161
if (! is_dir($dirPath)) {
167162
$dirPath = str_replace('/', DIRECTORY_SEPARATOR, __DIR__.'/../../.ai/'.$dirPath);
@@ -173,27 +168,21 @@ protected function guidelinesDir(string $dirPath): ?string
173168
->in($dirPath)
174169
->name('*.blade.php');
175170
} catch (DirectoryNotFoundException $e) {
176-
return null;
171+
return [];
177172
}
178173

179-
$guidelines = '';
180-
foreach ($finder as $file) {
181-
$guidelines .= $this->guideline($file->getRealPath()) ?? '';
182-
$guidelines .= PHP_EOL;
183-
}
184-
185-
return $guidelines;
174+
return array_map(fn ($file) => $this->guideline($file->getRealPath()), iterator_to_array($finder));
186175
}
187176

188-
protected function guideline(string $path): ?string
177+
/**
178+
* @param string $path
179+
* @return array{content: string, name: string, path: ?string, custom: bool}
180+
*/
181+
protected function guideline(string $path): array
189182
{
190-
if (! file_exists($path)) {
191-
$path = preg_replace('/\.blade\.php$/', '', $path);
192-
$path = str_replace('/', DIRECTORY_SEPARATOR, __DIR__.'/../../.ai/'.$path.'.blade.php');
193-
}
194-
195-
if (! file_exists($path)) {
196-
return null;
183+
$path = $this->guidelinePath($path);
184+
if (is_null($path)) {
185+
return ['content' => '', 'name' => '', 'path' => null, 'custom' => false];
197186
}
198187

199188
$content = file_get_contents($path);
@@ -214,7 +203,12 @@ protected function guideline(string $path): ?string
214203
$rendered = str_replace(array_keys($this->storedSnippets), array_values($this->storedSnippets), $rendered);
215204
$this->storedSnippets = []; // Clear for next use
216205

217-
return trim($rendered);
206+
return [
207+
'content' => trim($rendered),
208+
'name' => str_replace('.blade.php', '', basename($path)),
209+
'path' => $path,
210+
'custom' => str_contains($path, $this->customGuidelinePath()),
211+
];
218212
}
219213

220214
private array $storedSnippets = [];
@@ -233,4 +227,44 @@ private function processBoostSnippets(string $content): string
233227
return $placeholder;
234228
}, $content);
235229
}
230+
231+
protected function prependPackageGuidelinePath(string $path): string
232+
{
233+
$path = preg_replace('/\.blade\.php$/', '', $path);
234+
$path = str_replace('/', DIRECTORY_SEPARATOR, __DIR__.'/../../.ai/'.$path.'.blade.php');
235+
236+
return $path;
237+
}
238+
239+
protected function prependUserGuidelinePath(string $path): string
240+
{
241+
$path = preg_replace('/\.blade\.php$/', '', $path);
242+
$path = str_replace('/', DIRECTORY_SEPARATOR, $this->customGuidelinePath($path.'.blade.php'));
243+
244+
return $path;
245+
}
246+
247+
protected function guidelinePath(string $path): ?string
248+
{
249+
// Relative path, prepend our package path to it
250+
if (! file_exists($path)) {
251+
$path = $this->prependPackageGuidelinePath($path);
252+
if (! file_exists($path)) {
253+
return null;
254+
}
255+
}
256+
257+
$path = realpath($path);
258+
259+
// If this is a custom guideline, return it unchanged
260+
if (str_contains($path, $this->customGuidelinePath())) {
261+
return $path;
262+
}
263+
264+
// The path is not a custom guideline, check if the user has an override for this
265+
$relativePath = ltrim(str_replace([realpath(__DIR__.'/../../'), '.ai/'], '', $path), '/');
266+
$customPath = $this->prependUserGuidelinePath($relativePath);
267+
268+
return file_exists($customPath) ? $customPath : $path;
269+
}
236270
}

tests/Feature/Install/GuidelineComposerTest.php

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,9 @@
206206

207207
expect($guidelines)
208208
->toContain('=== inertia-react/core rules ===')
209-
->toContain('=== inertia-react/v2 rules ===')
209+
->toContain('=== inertia-react/v2/forms rules ===')
210210
->toContain('=== inertia-vue/core rules ===')
211-
->toContain('=== inertia-vue/v2 rules ===')
211+
->toContain('=== inertia-vue/v2/forms rules ===')
212212
->toContain('=== pest/core rules ===');
213213
});
214214

@@ -251,3 +251,50 @@
251251
->toContain('laravel/v11')
252252
->toContain('pest/core');
253253
});
254+
255+
test('includes user custom guidelines from .ai/guidelines directory', function () {
256+
$packages = new PackageCollection([
257+
new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'),
258+
]);
259+
260+
$this->roster->shouldReceive('packages')->andReturn($packages);
261+
262+
$composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd])->makePartial();
263+
$composer
264+
->shouldReceive('customGuidelinePath')
265+
->andReturnUsing(fn ($path = '') => realpath(\Pest\testDirectory('fixtures/.ai/guidelines')).'/'.ltrim($path, '/'));
266+
267+
expect($composer->compose())
268+
->toContain('=== .ai/custom-rule rules ===')
269+
->toContain('=== .ai/project-specific rules ===')
270+
->toContain('This is a custom project-specific guideline')
271+
->toContain('Project-specific coding standards')
272+
->toContain('Database tables must use `snake_case` naming')
273+
->and($composer->used())
274+
->toContain('.ai/custom-rule')
275+
->toContain('.ai/project-specific');
276+
});
277+
278+
test('non-empty custom guidelines override Boost guidelines', function () {
279+
$packages = new PackageCollection([
280+
new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'),
281+
]);
282+
283+
$this->roster->shouldReceive('packages')->andReturn($packages);
284+
285+
$composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd])->makePartial();
286+
$composer
287+
->shouldReceive('customGuidelinePath')
288+
->andReturnUsing(fn ($path = '') => realpath(\Pest\testDirectory('fixtures/.ai/guidelines')).'/'.ltrim($path, '/'));
289+
290+
$guidelines = $composer->compose();
291+
$overrideStringCount = substr_count($guidelines, 'Thanks though, appreciate you');
292+
293+
expect($overrideStringCount)->toBe(1)
294+
->and($guidelines)
295+
->toContain('Thanks though, appreciate you') // From user guidelines
296+
->not->toContain('## Laravel 11') // Heading from Boost's L11/core guideline
297+
->and($composer->used())
298+
->toContain('.ai/custom-rule')
299+
->toContain('.ai/project-specific');
300+
});

tests/Unit/Install/GuidelineComposerTest.php

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
This is a custom project-specific guideline.
2+
3+
Use the following conventions:
4+
- Always prefix custom classes with `Project`
5+
- Use camelCase for method names
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
I don't want your guidelines, I've got my own, and they're great.
2+
3+
Thanks though, appreciate you!
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Project-specific coding standards:
2+
3+
- Database tables must use `snake_case` naming
4+
- All controllers should extend `BaseController`
5+
- Use the `@assist->package('laravel')` helper when available

0 commit comments

Comments
 (0)