Skip to content

Commit 59383f8

Browse files
committed
feat: enable guideline overriding
1 parent 6e85a34 commit 59383f8

File tree

8 files changed

+154
-50
lines changed

8 files changed

+154
-50
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 with your own custom guidelines. Match your custom AI guideline path to an existing Boost guideline path, and Boost will install that instead.
103+
104+
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`.
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ 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($guidelines->map(fn ($guideline, $key) => $key.($guideline['custom'] ? '*' : ''))->sort()->toArray(), $this->terminal->cols());
394394
$this->newLine();
395395
usleep(750000);
396396

src/Install/GuidelineComposer.php

Lines changed: 85 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -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
/**
@@ -86,7 +92,6 @@ protected function find(): Collection
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,26 @@ 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 = '';
174+
$guidelines = [];
180175
foreach ($finder as $file) {
181-
$guidelines .= $this->guideline($file->getRealPath()) ?? '';
182-
$guidelines .= PHP_EOL;
176+
$guidelines[] = $this->guideline($file->getRealPath());
183177
}
184178

185179
return $guidelines;
186180
}
187181

188-
protected function guideline(string $path): ?string
182+
/**
183+
* @param string $path
184+
* @return array{content: string, name: string, path: ?string, custom: bool}
185+
*/
186+
protected function guideline(string $path): array
189187
{
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;
188+
$path = $this->guidelinePath($path);
189+
if (is_null($path)) {
190+
return ['content' => '', 'name' => '', 'path' => null, 'custom' => false];
197191
}
198192

199193
$content = file_get_contents($path);
@@ -214,7 +208,12 @@ protected function guideline(string $path): ?string
214208
$rendered = str_replace(array_keys($this->storedSnippets), array_values($this->storedSnippets), $rendered);
215209
$this->storedSnippets = []; // Clear for next use
216210

217-
return trim($rendered);
211+
return [
212+
'content' => trim($rendered),
213+
'name' => str_replace('.blade.php', '', basename($path)),
214+
'path' => $path,
215+
'custom' => str_contains($path, $this->customGuidelinePath()),
216+
];
218217
}
219218

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

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)