Skip to content
Open
182 changes: 97 additions & 85 deletions src/Install/GuidelineComposer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace Laravel\Boost\Install;

use const DIRECTORY_SEPARATOR;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Str;
Expand Down Expand Up @@ -113,106 +115,110 @@ public function guidelines(): Collection
return $this->guidelines;
}

return $this->guidelines = $this->find();
}

/**
* Key is the 'guideline key' and value is the rendered blade.
*
* @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'));
$base = collect()
->merge($this->getCoreGuidelines())
->merge($this->getConditionalGuidelines())
->merge($this->getPackageGuidelines())
->merge($this->getThirdPartyGuidelines());

// TODO: AI-48: Use composer target version, not PHP version. Production could be 8.1, but local is 8.4
// $phpMajorMinor = PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION;
// $guidelines->put('php/v'.$phpMajorMinor, $this->guidelinesDir('php/'.$phpMajorMinor));
$basePaths = $base->pluck('path')->filter()->values();

if (str_contains((string) config('app.url'), '.test') && $this->herd->isInstalled() && ! $this->config->usesSail) {
$guidelines->put('herd', $this->guideline('herd/core'));
}
$customGuidelines = $this->getUserGuidelines()
->reject(fn ($guideline): bool => $basePaths->contains($guideline['path']));

if ($this->config->usesSail) {
$guidelines->put('sail', $this->guideline('sail/core'));
}

if ($this->config->laravelStyle) {
$guidelines->put('laravel/style', $this->guideline('laravel/style'));
}
return $this->guidelines = $customGuidelines
->merge($base)
->filter(fn ($guideline): bool => ! empty(trim((string) $guideline['content'])));
}

if ($this->config->hasAnApi) {
$guidelines->put('laravel/api', $this->guideline('laravel/api'));
}
protected function getUserGuidelines(): Collection
{
return collect($this->guidelinesDir($this->customGuidelinePath()))
->mapWithKeys(fn ($guideline): array => ['.ai/'.$guideline['name'] => $guideline]);
}

if ($this->config->caresAboutLocalization) {
$guidelines->put('laravel/localization', $this->guideline('laravel/localization'));
// In future, if using NextJS localization/etc.. then have a diff. rule here
}
protected function getCoreGuidelines(): Collection
{
return collect([
'foundation' => $this->guideline('foundation'),
'boost' => $this->guideline('boost/core'),
'php' => $this->guideline('php/core'),
]);
}

// Add all core and version specific docs for Roster supported packages
// We don't add guidelines for packages unsupported by Roster right now
foreach ($this->roster->packages() as $package) {
// Skip packages that should be excluded due to priority rules
if ($this->shouldExcludePackage($package)) {
continue;
}
protected function getConditionalGuidelines(): Collection
{
return collect([
'herd' => [
'condition' => str_contains((string) config('app.url'), '.test') && $this->herd->isInstalled() && ! $this->config->usesSail,
'path' => 'herd/core',
],
'sail' => [
'condition' => $this->config->usesSail,
'path' => 'sail/core',
],
'laravel/style' => [
'condition' => $this->config->laravelStyle,
'path' => 'laravel/style',
],
'laravel/api' => [
'condition' => $this->config->hasAnApi,
'path' => 'laravel/api',
],
'laravel/localization' => [
'condition' => $this->config->caresAboutLocalization,
'path' => 'laravel/localization',
],
'tests' => [
'condition' => $this->config->enforceTests,
'path' => 'enforce-tests',
],
])
->filter(fn ($config): bool => $config['condition'])
->mapWithKeys(fn ($config, $key): array => [$key => $this->guideline($config['path'])]);
}

$guidelineDir = str_replace('_', '-', strtolower($package->name()));

$guidelines->put(
$guidelineDir.'/core',
$this->guideline($guidelineDir.'/core')
); // Always add package core
$packageGuidelines = $this->guidelinesDir($guidelineDir.'/'.$package->majorVersion());
foreach ($packageGuidelines as $guideline) {
$suffix = $guideline['name'] === 'core' ? '' : '/'.$guideline['name'];
$guidelines->put(
$guidelineDir.'/v'.$package->majorVersion().$suffix,
$guideline
);
}
}
protected function getPackageGuidelines(): Collection
{
return $this->roster->packages()
->reject(fn (Package $package): bool => $this->shouldExcludePackage($package))
->flatMap(function ($package): Collection {
$guidelineDir = str_replace('_', '-', strtolower($package->name()));
$guidelines = collect([$guidelineDir.'/core' => $this->guideline($guidelineDir.'/core')]);
$packageGuidelines = $this->guidelinesDir($guidelineDir.'/'.$package->majorVersion());

if ($this->config->enforceTests) {
$guidelines->put('tests', $this->guideline('enforce-tests'));
}
foreach ($packageGuidelines as $guideline) {
$suffix = $guideline['name'] === 'core' ? '' : '/'.$guideline['name'];

$userGuidelines = $this->guidelinesDir($this->customGuidelinePath());
$pathsUsed = $guidelines->pluck('path');
$guidelines->put(
$guidelineDir.'/v'.$package->majorVersion().$suffix,
$guideline
);
}

foreach ($userGuidelines as $guideline) {
if ($pathsUsed->contains($guideline['path'])) {
continue; // Don't include this twice if it's an override
}
return $guidelines;
});
}

$guidelines->put('.ai/'.$guideline['name'], $guideline);
}
protected function getThirdPartyGuidelines(): Collection
{
$guidelines = collect();

collect(Composer::packagesDirectoriesWithBoostGuidelines())
->each(function (string $path, string $package) use ($guidelines): void {
$packageGuidelines = $this->guidelinesDir($path, true);
$pathsUsed = $guidelines->pluck('path');

foreach ($packageGuidelines as $guideline) {
if ($pathsUsed->contains($guideline['path'])) {
continue; // Don't include this twice if it's an override
}

$guidelines->put($package, $guideline);
}
})->when(
isset($this->config->aiGuidelines),
fn (Collection $collection): Collection => $collection->filter(
fn (string $name): bool => in_array($name, $this->config->aiGuidelines, true),
)
);

return $guidelines
->where(fn (array $guideline): bool => ! empty(trim((string) $guideline['content'])));
});

return $guidelines->when(
isset($this->config->aiGuidelines),
fn (Collection $collection): Collection => $collection->filter(
fn (mixed $guideline, string $name): bool => in_array($name, $this->config->aiGuidelines, true),
)
);
}

/**
Expand All @@ -236,7 +242,7 @@ protected function shouldExcludePackage(Package $package): bool
}

/**
* @return array<array{content: string, name: string, path: ?string, custom: bool}>
* @return array<array{content: string, name: string, description: string, path: ?string, custom: bool, third_party: bool}>
*/
protected function guidelinesDir(string $dirPath, bool $thirdParty = false): array
{
Expand All @@ -254,7 +260,9 @@ protected function guidelinesDir(string $dirPath, bool $thirdParty = false): arr
return [];
}

return array_map(fn (SplFileInfo $file): array => $this->guideline($file->getRealPath(), $thirdParty), iterator_to_array($finder));
return collect($finder)
->map(fn (SplFileInfo $file): array => $this->guideline($file->getRealPath(), $thirdParty))
->all();
}

protected function renderContent(string $content, string $path): string
Expand Down Expand Up @@ -312,7 +320,7 @@ protected function guideline(string $path, bool $thirdParty = false): array
->after('# ')
->before("\n")
->trim()
->limit(50, '...')
->limit(50)
->whenEmpty(fn () => Str::of('No description provided'))
->value();

Expand Down Expand Up @@ -387,7 +395,11 @@ protected function guidelinePath(string $path): ?string

// The path is not a custom guideline, check if the user has an override for this
$basePath = realpath(__DIR__.'/../../');
$relativePath = ltrim(str_replace([$basePath, '.ai'.DIRECTORY_SEPARATOR, '.ai/'], '', $path), '/\\');
$relativePath = Str::of($path)
->replace([$basePath, '.ai'.DIRECTORY_SEPARATOR, '.ai/'], '')
->ltrim('/\\')
->toString();

$customPath = $this->prependUserGuidelinePath($relativePath);

return file_exists($customPath) ? $customPath : $path;
Expand Down
134 changes: 132 additions & 2 deletions tests/Feature/Install/GuidelineComposerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,10 @@

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
->toContain('Thanks though, appreciate you')
->not->toContain('## Laravel 11')
->toContain('=== laravel/v11 rules ===')
->not->toContain('=== .ai/core rules ===')
->and($composer->used())
->toContain('.ai/custom-rule')
->toContain('.ai/project-specific');
Expand Down Expand Up @@ -633,3 +635,131 @@
->not->toContain('Wayfinder + Inertia')
->not->toContain('Wayfinder Form Component');
});

test('the guidelines are in correct order', function (): void {
$composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd])->makePartial();
$composer
->shouldReceive('customGuidelinePath')
->andReturnUsing(fn ($path = ''): string => realpath(testDirectory('fixtures/.ai/guidelines')).'/'.ltrim((string) $path, '/'));

$packages = new PackageCollection([
new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'),
new Package(Packages::PEST, 'pestphp/pest', '3.0.0'),
]);
$this->roster->shouldReceive('packages')->andReturn($packages);

$config = new GuidelineConfig;
$config->enforceTests = true;
$this->herd->shouldReceive('isInstalled')->andReturn(false);
$composer->config($config);

$guidelines = $composer->guidelines();
$keys = $guidelines->keys()->toArray();

$firstUserGuidelinePos = collect($keys)->search(fn ($key): bool => str_starts_with((string) $key, '.ai/'));
$foundationPos = array_search('foundation', $keys, true);
$testsPos = array_search('tests', $keys, true);
$pestPos = collect($keys)->search(fn ($key): bool => str_starts_with((string) $key, 'pest/'));

expect($firstUserGuidelinePos)->not->toBeFalse()
->and($foundationPos)->not->toBeFalse()
->and($testsPos)->not->toBeFalse()
->and($pestPos)->not->toBeFalse()
->and($firstUserGuidelinePos)->toBeLessThan($foundationPos)
->and($foundationPos)->toBeLessThan($testsPos)
->and($testsPos)->toBeLessThan($pestPos);
});

test('excludes FluxUI Free guidelines when FluxUI Pro is present', function (): void {
$packages = new PackageCollection([
new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'),
new Package(Packages::FLUXUI_PRO, 'livewire/flux-pro', '1.0.0'),
new Package(Packages::FLUXUI_FREE, 'livewire/flux', '1.0.0'),
]);

$this->roster->shouldReceive('packages')->andReturn($packages);
$this->roster->shouldReceive('uses')->with(Packages::FLUXUI_PRO)->andReturn(true);

$guidelines = $this->composer->guidelines();
$keys = $guidelines->keys()->toArray();

$hasFluxPro = collect($keys)->contains(fn ($key): bool => str_contains((string) $key, 'fluxui-pro/'));
$hasFluxFree = collect($keys)->contains(fn ($key): bool => str_contains((string) $key, 'fluxui-free/'));

expect($hasFluxPro)->toBeTrue()
->and($hasFluxFree)->toBeFalse();
});

test('composeGuidelines filters out empty guidelines', function (): void {
$guidelines = collect([
'test/empty' => [
'content' => ' ',
'name' => 'empty',
'path' => '/path/to/empty.md',
'custom' => false,
],
'test/valid' => [
'content' => 'Valid content',
'name' => 'valid',
'path' => '/path/to/valid.md',
'custom' => false,
],
]);

$composed = GuidelineComposer::composeGuidelines($guidelines);

expect($composed)
->toContain('=== test/valid rules ===')
->toContain('Valid content')
->not->toContain('=== test/empty rules ===');
});

test('correctly converts package names to hyphens in guideline paths', function (): void {
$packages = new PackageCollection([
new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'),
new Package(Packages::INERTIA_REACT, 'inertiajs/inertia-react', '2.1.0'),
]);

$this->roster->shouldReceive('packages')->andReturn($packages);
$this->roster->shouldReceive('usesVersion')->andReturn(false);

$guidelines = $this->composer->guidelines();
$keys = $guidelines->keys()->toArray();

$hasHyphenated = collect($keys)->contains(fn ($key): bool => str_starts_with((string) $key, 'inertia-react/'));
$hasUnderscored = collect($keys)->contains(fn ($key): bool => str_starts_with((string) $key, 'inertia_react/'));

expect($hasHyphenated)->toBeTrue()
->and($hasUnderscored)->toBeFalse();
});

test('includes enabled conditional guidelines and orders them before packages', function (): void {
$packages = new PackageCollection([
new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'),
new Package(Packages::PEST, 'pestphp/pest', '3.0.0'),
]);

$this->roster->shouldReceive('packages')->andReturn($packages);
$this->herd->shouldReceive('isInstalled')->andReturn(true);
config(['app.url' => 'http://myapp.test']);

$config = new GuidelineConfig;
$config->enforceTests = true;

$guidelines = $this->composer->config($config)->guidelines();
$keys = $guidelines->keys()->toArray();

expect($keys)
->toContain('herd')
->toContain('tests');

$foundationPos = array_search('foundation', $keys, true);
$testsPos = array_search('tests', $keys, true);
$pestPos = collect($keys)->search(fn ($key): bool => str_starts_with((string) $key, 'pest/'));

expect($foundationPos)->not->toBeFalse()
->and($testsPos)->not->toBeFalse()
->and($pestPos)->not->toBeFalse()
->and($testsPos)->toBeGreaterThan($foundationPos)
->and($testsPos)->toBeLessThan($pestPos);
});