diff --git a/.ai/inertia-laravel/1/core.blade.php b/.ai/inertia-laravel/1/core.blade.php index 78ae1782..41274d33 100644 --- a/.ai/inertia-laravel/1/core.blade.php +++ b/.ai/inertia-laravel/1/core.blade.php @@ -1,3 +1,16 @@ +# Inertia + +- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns. +- Components live in `{{ $assist->inertia()->pagesDirectory() }}` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views. +- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples. +@if($assist->hasPackage(\Laravel\Roster\Enums\Packages::INERTIA_REACT)) +- IMPORTANT: Activate `inertia-react-development` when working with Inertia client-side patterns. +@elseif($assist->hasPackage(\Laravel\Roster\Enums\Packages::INERTIA_VUE)) +- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. +@elseif($assist->hasPackage(\Laravel\Roster\Enums\Packages::INERTIA_SVELTE)) +- IMPORTANT: Activate `inertia-svelte-development` when working with Inertia Svelte client-side patterns. +@endif + # Inertia v1 - Inertia v1 does not support the following v2 features: deferred props, infinite scrolling (merging props + `WhenVisible`), lazy loading on scroll, polling, or prefetching. Do not use these. diff --git a/.ai/inertia-laravel/2/core.blade.php b/.ai/inertia-laravel/2/core.blade.php index 38365a29..16a25263 100644 --- a/.ai/inertia-laravel/2/core.blade.php +++ b/.ai/inertia-laravel/2/core.blade.php @@ -1,3 +1,16 @@ +# Inertia + +- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns. +- Components live in `{{ $assist->inertia()->pagesDirectory() }}` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views. +- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples. +@if($assist->hasPackage(\Laravel\Roster\Enums\Packages::INERTIA_REACT)) +- IMPORTANT: Activate `inertia-react-development` when working with Inertia client-side patterns. +@elseif($assist->hasPackage(\Laravel\Roster\Enums\Packages::INERTIA_VUE)) +- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. +@elseif($assist->hasPackage(\Laravel\Roster\Enums\Packages::INERTIA_SVELTE)) +- IMPORTANT: Activate `inertia-svelte-development` when working with Inertia Svelte client-side patterns. +@endif + # Inertia v2 - Use all Inertia features from v1 and v2. Check the documentation before making changes to ensure the correct approach. diff --git a/.ai/inertia-laravel/core.blade.php b/.ai/inertia-laravel/core.blade.php deleted file mode 100644 index f623abeb..00000000 --- a/.ai/inertia-laravel/core.blade.php +++ /dev/null @@ -1,12 +0,0 @@ -# Inertia - -- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns. -- Components live in `{{ $assist->inertia()->pagesDirectory() }}` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views. -- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples. -@if($assist->hasPackage(\Laravel\Roster\Enums\Packages::INERTIA_REACT)) -- IMPORTANT: Activate `inertia-react-development` when working with Inertia client-side patterns. -@elseif($assist->hasPackage(\Laravel\Roster\Enums\Packages::INERTIA_VUE)) -- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns. -@elseif($assist->hasPackage(\Laravel\Roster\Enums\Packages::INERTIA_SVELTE)) -- IMPORTANT: Activate `inertia-svelte-development` when working with Inertia Svelte client-side patterns. -@endif diff --git a/composer.json b/composer.json index fe217573..2ca02a7b 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "illuminate/support": "^11.45.3|^12.41.1", "laravel/mcp": "^0.5.1", "laravel/prompts": "^0.3.10", - "laravel/roster": "^0.4.0" + "laravel/roster": "^0.5.0" }, "require-dev": { "laravel/pint": "^1.27.0", diff --git a/src/Install/Concerns/DiscoverPackagePaths.php b/src/Install/Concerns/DiscoverPackagePaths.php index 593cce2d..bbc554ed 100644 --- a/src/Install/Concerns/DiscoverPackagePaths.php +++ b/src/Install/Concerns/DiscoverPackagePaths.php @@ -5,6 +5,8 @@ namespace Laravel\Boost\Install\Concerns; use Illuminate\Support\Collection; +use Laravel\Boost\Support\Composer; +use Laravel\Boost\Support\Npm; use Laravel\Roster\Enums\Packages; use Laravel\Roster\Package; use Laravel\Roster\Roster; @@ -96,4 +98,15 @@ protected function getBoostAiPath(): string { return __DIR__.'/../../../.ai'; } + + protected function resolveFirstPartyBoostPath(Package $package, string $subpath): ?string + { + if (! Composer::isFirstPartyPackage($package->rawName()) && ! Npm::isFirstPartyPackage($package->rawName())) { + return null; + } + + $path = implode(DIRECTORY_SEPARATOR, [$package->path(), 'resources', 'boost', $subpath]); + + return is_dir($path) ? $path : null; + } } diff --git a/src/Install/GuidelineComposer.php b/src/Install/GuidelineComposer.php index 1e61ea78..a182618f 100644 --- a/src/Install/GuidelineComposer.php +++ b/src/Install/GuidelineComposer.php @@ -10,7 +10,6 @@ use Laravel\Boost\Install\Concerns\DiscoverPackagePaths; use Laravel\Boost\Support\Composer; use Laravel\Roster\Package; -use Laravel\Roster\PackageCollection; use Laravel\Roster\Roster; use Symfony\Component\Finder\Exception\DirectoryNotFoundException; use Symfony\Component\Finder\Finder; @@ -169,13 +168,23 @@ protected function getConditionalGuidelines(): Collection ->mapWithKeys(fn ($config, $key): array => [$key => $this->guideline($config['path'])]); } - protected function getPackageGuidelines(): PackageCollection + protected function getPackageGuidelines(): Collection { return $this->roster->packages() ->reject(fn (Package $package): bool => $this->shouldExcludePackage($package)) - ->flatMap(function ($package): Collection { + ->flatMap(function (Package $package): Collection { $guidelineDir = $this->normalizePackageName($package->name()); - $guidelines = collect([$guidelineDir.'/core' => $this->guideline($guidelineDir.'/core')]); + + $vendorPath = $this->resolveFirstPartyBoostPath($package, 'guidelines'); + + $vendorCorePath = $vendorPath !== null + ? implode(DIRECTORY_SEPARATOR, [$vendorPath, 'core']) + : null; + + $guidelines = collect([ + $guidelineDir.'/core' => $this->resolveGuideline($vendorCorePath, $guidelineDir.'/core'), + ]); + $packageGuidelines = $this->guidelinesDir($guidelineDir.'/'.$package->majorVersion()); foreach ($packageGuidelines as $guideline) { @@ -191,6 +200,22 @@ protected function getPackageGuidelines(): PackageCollection }); } + /** + * @return array{content: string, name: string, description: string, path: ?string, custom: bool, third_party: bool} + */ + private function resolveGuideline(?string $vendorPath, string $guidelineKey): array + { + if ($vendorPath !== null) { + foreach (['.blade.php', '.md'] as $ext) { + if (file_exists($vendorPath.$ext)) { + return $this->guideline($vendorPath.$ext, false, $guidelineKey); + } + } + } + + return $this->guideline($guidelineKey); + } + /** * @return Collection */ @@ -199,6 +224,10 @@ protected function getThirdPartyGuidelines(): Collection $guidelines = collect(); foreach (Composer::packagesDirectoriesWithBoostGuidelines() as $package => $path) { + if (Composer::isFirstPartyPackage($package)) { + continue; + } + foreach ($this->guidelinesDir($path, true) as $guideline) { $guidelines->put($package, $guideline); } @@ -242,9 +271,9 @@ protected function guidelinesDir(string $dirPath, bool $thirdParty = false): arr /** * @return array{content: string, name: string, description: string, path: ?string, custom: bool, third_party: bool} */ - protected function guideline(string $path, bool $thirdParty = false): array + protected function guideline(string $path, bool $thirdParty = false, ?string $overrideKey = null): array { - $path = $this->guidelinePath($path); + $path = $this->guidelinePath($path, $overrideKey); if ($path === null) { return [ @@ -304,7 +333,7 @@ private function prependGuidelinePath(string $path, string $basePath): string return str_replace('/', DIRECTORY_SEPARATOR, $basePath.$path); } - protected function guidelinePath(string $path): ?string + protected function guidelinePath(string $path, ?string $overrideKey = null): ?string { // Relative path, prepend our package path to it if (! file_exists($path)) { @@ -322,6 +351,18 @@ protected function guidelinePath(string $path): ?string return $path; } + if ($overrideKey !== null) { + foreach (['.blade.php', '.md'] as $ext) { + $customPath = $this->prependUserGuidelinePath($overrideKey.$ext); + + if (file_exists($customPath)) { + return $customPath; + } + } + + return $path; + } + // The path is not a custom guideline, check if the user has an override for this $basePath = realpath(__DIR__.'/../../'); $relativePath = Str::of($path) diff --git a/src/Install/SkillComposer.php b/src/Install/SkillComposer.php index 7c3addd7..d804c44c 100644 --- a/src/Install/SkillComposer.php +++ b/src/Install/SkillComposer.php @@ -9,6 +9,7 @@ use Laravel\Boost\Concerns\RendersBladeGuidelines; use Laravel\Boost\Install\Concerns\DiscoverPackagePaths; use Laravel\Boost\Support\Composer; +use Laravel\Roster\Package; use Laravel\Roster\Roster; use Symfony\Component\Yaml\Yaml; @@ -63,12 +64,28 @@ public function skills(): Collection */ protected function getBoostSkills(): Collection { - return $this->discoverPackagePaths($this->getBoostAiPath()) - ->flatMap(fn (array $package): Collection => $this->discoverSkillsFromPath( - $package['path'], - $package['name'], - $package['version'] - )); + /** @var Collection $skills */ + $skills = $this->getRoster()->packages() + ->reject(fn (Package $package): bool => $this->shouldExcludePackage($package)) + ->collect() + ->flatMap(function (Package $package): Collection { + $name = $this->normalizePackageName($package->name()); + + $vendorSkillPath = $this->resolveFirstPartyBoostPath($package, 'skills'); + + $vendorSkills = $vendorSkillPath !== null + ? $this->discoverSkillsFromDirectory($vendorSkillPath, $name) + : collect(); + + $aiPath = $this->getBoostAiPath().DIRECTORY_SEPARATOR.$name; + $aiSkills = is_dir($aiPath) + ? $this->discoverSkillsFromPath($aiPath, $name, $package->majorVersion()) + : collect(); + + return $aiSkills->merge($vendorSkills); + }); + + return $skills; } /** @@ -77,6 +94,7 @@ protected function getBoostSkills(): Collection protected function getThirdPartySkills(): Collection { $skills = collect(Composer::packagesDirectoriesWithBoostSkills()) + ->reject(fn (string $path, string $package): bool => Composer::isFirstPartyPackage($package)) ->flatMap(fn (string $path, string $package): Collection => $this->discoverSkillsFromDirectory($path, $package)); $selectedPackages = $this->config->aiGuidelines ?? []; diff --git a/src/Install/ThirdPartyPackage.php b/src/Install/ThirdPartyPackage.php index a1086246..8543e0bf 100644 --- a/src/Install/ThirdPartyPackage.php +++ b/src/Install/ThirdPartyPackage.php @@ -33,6 +33,7 @@ public static function discover(): Collection )); return collect($allPackageNames) + ->reject(fn (string $name): bool => Composer::isFirstPartyPackage($name)) ->mapWithKeys(fn (string $name): array => [ $name => new self( name: $name, diff --git a/src/Support/Composer.php b/src/Support/Composer.php index 14eeb0d4..0ea27a34 100644 --- a/src/Support/Composer.php +++ b/src/Support/Composer.php @@ -6,6 +6,29 @@ class Composer { + /** @var array */ + public const FIRST_PARTY_PACKAGES = [ + 'laravel/framework', + 'laravel/folio', + 'laravel/mcp', + 'laravel/pennant', + 'laravel/pint', + 'laravel/sail', + 'laravel/wayfinder', + 'livewire/livewire', + 'livewire/flux', + 'livewire/flux-pro', + 'livewire/volt', + 'inertiajs/inertia-laravel', + 'pestphp/pest', + 'phpunit/phpunit', + ]; + + public static function isFirstPartyPackage(string $composerName): bool + { + return in_array($composerName, self::FIRST_PARTY_PACKAGES, true); + } + public static function packagesDirectories(): array { return collect(static::packages()) @@ -48,9 +71,9 @@ public static function packagesDirectoriesWithBoostSkills(): array } /** - * @param string|null $subpath Optional subpath under resources/boost/ (e.g., 'guidelines') + * @return array */ - private static function packagesDirectoriesWithBoostSubpath(?string $subpath = null): array + private static function packagesDirectoriesWithBoostSubpath(string $subpath): array { return collect(self::packagesDirectories()) ->map(fn (string $path): string => implode(DIRECTORY_SEPARATOR, array_filter([ diff --git a/src/Support/Npm.php b/src/Support/Npm.php new file mode 100644 index 00000000..0470dc73 --- /dev/null +++ b/src/Support/Npm.php @@ -0,0 +1,99 @@ + */ + public const FIRST_PARTY_SCOPES = [ + '@inertiajs', + '@laravel', + ]; + + /** @var array */ + public const FIRST_PARTY_PACKAGES = [ + 'laravel-echo', + 'laravel-precognition', + 'laravel-vite-plugin', + ]; + + public static function isFirstPartyPackage(string $npmName): bool + { + if (collect(self::FIRST_PARTY_SCOPES)->contains(fn (string $scope): bool => str_starts_with($npmName, $scope.'/'))) { + return true; + } + + return in_array($npmName, self::FIRST_PARTY_PACKAGES, true); + } + + /** + * @return array + */ + public static function packagesDirectories(): array + { + return collect(static::packages()) + ->mapWithKeys(fn (string $key, string $package): array => [$package => implode(DIRECTORY_SEPARATOR, [ + base_path('node_modules'), + str_replace('/', DIRECTORY_SEPARATOR, $package), + ])]) + ->filter(fn (string $path): bool => is_dir($path)) + ->toArray(); + } + + /** + * @return array + */ + public static function packages(): array + { + $packageJsonPath = base_path('package.json'); + + if (! file_exists($packageJsonPath)) { + return []; + } + + $packageData = json_decode(file_get_contents($packageJsonPath), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return []; + } + + return collect($packageData['dependencies'] ?? []) + ->merge($packageData['devDependencies'] ?? []) + ->mapWithKeys(fn (string $key, string $package): array => [$package => $key]) + ->toArray(); + } + + /** + * @return array + */ + public static function packagesDirectoriesWithBoostGuidelines(): array + { + return self::packagesDirectoriesWithBoostSubpath('guidelines'); + } + + /** + * @return array + */ + public static function packagesDirectoriesWithBoostSkills(): array + { + return self::packagesDirectoriesWithBoostSubpath('skills'); + } + + /** + * @return array + */ + private static function packagesDirectoriesWithBoostSubpath(string $subpath): array + { + return collect(self::packagesDirectories()) + ->map(fn (string $path): string => implode(DIRECTORY_SEPARATOR, array_filter([ + $path, + 'resources', + 'boost', + $subpath, + ]))) + ->filter(fn (string $path): bool => is_dir($path)) + ->toArray(); + } +} diff --git a/tests/Feature/Install/GuidelineComposerTest.php b/tests/Feature/Install/GuidelineComposerTest.php index 395c6d5a..714480bb 100644 --- a/tests/Feature/Install/GuidelineComposerTest.php +++ b/tests/Feature/Install/GuidelineComposerTest.php @@ -5,6 +5,8 @@ use Laravel\Boost\Install\GuidelineComposer; use Laravel\Boost\Install\GuidelineConfig; use Laravel\Boost\Install\Herd; +use Laravel\Boost\Support\Composer; +use Laravel\Boost\Support\Npm; use Laravel\Roster\Enums\NodePackageManager; use Laravel\Roster\Enums\Packages; use Laravel\Roster\Package; @@ -200,9 +202,6 @@ ->with(Packages::INERTIA_VUE, '2.1.0', '>=') ->andReturn(false); - $this->roster->shouldReceive('usesVersion') - ->with(Packages::INERTIA, '2.1.2', '>=') - ->andReturn(false); $this->roster->shouldReceive('usesVersion') ->with(Packages::INERTIA_REACT, '2.1.2', '>=') ->andReturn(false); @@ -939,3 +938,180 @@ ->toContain('## Skills Activation') ->toContain('This project has domain-specific skills available'); }); + +test('loads vendor core guideline when available', 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); + + $vendorFixture = realpath(testDirectory('Fixtures/vendor-guidelines/core-only')); + + $composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('resolveFirstPartyBoostPath') + ->andReturnUsing(fn (Package $package, string $subpath): ?string => $package->rawName() === 'pestphp/pest' ? $vendorFixture : null); + + $guidelines = $composer->compose(); + + expect($guidelines) + ->toContain('Vendor Core Guideline') + ->toContain('loaded from the vendor directory'); +}); + +test('falls back to .ai/ when vendor guideline path does not exist', 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); + + $composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('resolveFirstPartyBoostPath')->andReturn(null); + + $guidelines = $composer->compose(); + + expect($guidelines)->toContain('=== pest/core rules ==='); +}); + +test('guideline key is unchanged regardless of vendor or .ai/ source', 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); + + $vendorFixture = realpath(testDirectory('Fixtures/vendor-guidelines/core-only')); + + $composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('resolveFirstPartyBoostPath') + ->andReturnUsing(fn (Package $package, string $subpath): ?string => $package->rawName() === 'pestphp/pest' ? $vendorFixture : null); + + $keys = $composer->used(); + + expect($keys)->toContain('pest/core'); +}); + +test('user override works with vendor-sourced guideline', function (): void { + $packages = new PackageCollection([ + new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'), + ]); + + $this->roster->shouldReceive('packages')->andReturn($packages); + + $vendorFixture = realpath(testDirectory('Fixtures/vendor-guidelines/core-only')); + + $composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('customGuidelinePath') + ->andReturnUsing(fn ($path = ''): string => realpath(testDirectory('Fixtures/.ai/guidelines')).'/'.ltrim((string) $path, '/')); + $composer->shouldReceive('resolveFirstPartyBoostPath') + ->andReturnUsing(fn (Package $package, string $subpath): ?string => $package->rawName() === 'laravel/framework' ? $vendorFixture : null); + + $guidelines = $composer->guidelines(); + $laravelCore = $guidelines->get('laravel/core'); + + expect($laravelCore)->not->toBeNull() + ->and($laravelCore['content'])->toContain('User Override Laravel Core') + ->and($laravelCore['content'])->not->toContain('Vendor Core Guideline'); +}); + +test('isFirstPartyPackage identifies known packages', function (): void { + expect(Composer::isFirstPartyPackage('laravel/framework'))->toBeTrue() + ->and(Composer::isFirstPartyPackage('livewire/livewire'))->toBeTrue() + ->and(Composer::isFirstPartyPackage('pestphp/pest'))->toBeTrue() + ->and(Composer::isFirstPartyPackage('some/third-party'))->toBeFalse(); +}); + +test('isFirstPartyPackage identifies scoped npm packages', function (): void { + expect(Npm::isFirstPartyPackage('@inertiajs/react'))->toBeTrue() + ->and(Npm::isFirstPartyPackage('@inertiajs/vue3'))->toBeTrue() + ->and(Npm::isFirstPartyPackage('@laravel/vite-plugin-wayfinder'))->toBeTrue() + ->and(Npm::isFirstPartyPackage('some-npm-package'))->toBeFalse() + ->and(Npm::isFirstPartyPackage('@other/package'))->toBeFalse(); +}); + +test('loads node_modules core guideline for npm first-party packages', function (): void { + $packages = new PackageCollection([ + new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'), + new Package(Packages::INERTIA_REACT, '@inertiajs/react', '2.1.0'), + ]); + + $this->roster->shouldReceive('packages')->andReturn($packages); + + $vendorFixture = realpath(testDirectory('Fixtures/vendor-guidelines/core-only')); + + $composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('resolveFirstPartyBoostPath') + ->andReturnUsing(fn (Package $package, string $subpath): ?string => $package->rawName() === '@inertiajs/react' ? $vendorFixture : null); + + $guidelines = $composer->compose(); + + expect($guidelines) + ->toContain('Vendor Core Guideline') + ->toContain('loaded from the vendor directory'); +}); + +test('falls back to .ai/ when node_modules guideline path does not exist for npm package', function (): void { + $packages = new PackageCollection([ + new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'), + new Package(Packages::INERTIA_REACT, '@inertiajs/react', '2.1.0'), + ]); + + $this->roster->shouldReceive('packages')->andReturn($packages); + + $composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('resolveFirstPartyBoostPath')->andReturn(null); + + $guidelines = $composer->compose(); + + expect($guidelines)->toContain('=== inertia-react/core rules ==='); +}); + +test('user override resolves .md files for vendor-sourced guidelines', 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); + + $vendorFixture = realpath(testDirectory('Fixtures/vendor-guidelines/core-only')); + + $mdOverrideDir = testDirectory('Fixtures/.ai/guidelines-md-override'); + @mkdir($mdOverrideDir.'/pest', 0755, true); + file_put_contents($mdOverrideDir.'/pest/core.md', '# Pest Markdown Override'); + + $composer = Mockery::mock(GuidelineComposer::class, [$this->roster, $this->herd]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('resolveFirstPartyBoostPath') + ->andReturnUsing(fn (Package $package, string $subpath): ?string => $package->rawName() === 'pestphp/pest' ? $vendorFixture : null); + $composer->shouldReceive('customGuidelinePath') + ->andReturnUsing(fn ($path = ''): string => $mdOverrideDir.'/'.ltrim((string) $path, '/')); + + $guidelines = $composer->guidelines(); + $pestCore = $guidelines->get('pest/core'); + + expect($pestCore)->not->toBeNull() + ->and($pestCore['content'])->toContain('Pest Markdown Override') + ->and($pestCore['content'])->not->toContain('Vendor Core Guideline'); + + @unlink($mdOverrideDir.'/pest/core.md'); + @rmdir($mdOverrideDir.'/pest'); + @rmdir($mdOverrideDir); +}); diff --git a/tests/Feature/Install/SkillComposerTest.php b/tests/Feature/Install/SkillComposerTest.php index 6ce329b3..5c5a18f9 100644 --- a/tests/Feature/Install/SkillComposerTest.php +++ b/tests/Feature/Install/SkillComposerTest.php @@ -160,6 +160,88 @@ expect($skills->has('livewire-development'))->toBeTrue(); }); +test('vendor skills override .ai/ skills with the same name', function (): void { + $packages = new PackageCollection([ + new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'), + (new Package(Packages::LIVEWIRE, 'livewire/livewire', '3.0.0'))->setDirect(true), + ]); + + $this->roster->shouldReceive('packages')->andReturn($packages); + + $vendorFixture = realpath(\Pest\testDirectory('Fixtures/vendor-skills')); + expect($vendorFixture)->not->toBeFalse(); + + $composer = Mockery::mock(SkillComposer::class, [$this->roster]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('resolveFirstPartyBoostPath') + ->andReturnUsing(fn (Package $package, string $subpath): ?string => $package->rawName() === 'livewire/livewire' ? $vendorFixture : null); + + $skills = $composer->skills(); + + expect($skills->has('livewire-development'))->toBeTrue() + ->and($skills->get('livewire-development')->description)->toBe('Vendor-overridden Livewire skill'); +}); + +test('falls back to .ai/ skills when vendor has none', function (): void { + $packages = new PackageCollection([ + new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'), + (new Package(Packages::LIVEWIRE, 'livewire/livewire', '3.0.0'))->setDirect(true), + ]); + + $this->roster->shouldReceive('packages')->andReturn($packages); + + $composer = Mockery::mock(SkillComposer::class, [$this->roster]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('resolveFirstPartyBoostPath')->andReturn(null); + + $skills = $composer->skills(); + + expect($skills->has('livewire-development'))->toBeTrue(); +}); + +test('node_modules skills override .ai/ skills for npm first-party packages', function (): void { + $packages = new PackageCollection([ + new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'), + new Package(Packages::INERTIA_REACT, '@inertiajs/react', '2.1.0'), + ]); + + $this->roster->shouldReceive('packages')->andReturn($packages); + + $vendorFixture = realpath(\Pest\testDirectory('Fixtures/vendor-skills')); + expect($vendorFixture)->not->toBeFalse(); + + $composer = Mockery::mock(SkillComposer::class, [$this->roster]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('resolveFirstPartyBoostPath') + ->andReturnUsing(fn (Package $package, string $subpath): ?string => $package->rawName() === '@inertiajs/react' ? $vendorFixture : null); + + $skills = $composer->skills(); + + $npmSkill = $skills->first(fn ($skill): bool => $skill->description === 'Vendor-overridden Livewire skill'); + expect($npmSkill)->not->toBeNull(); +}); + +test('falls back to .ai/ skills when node_modules has none for npm package', function (): void { + $packages = new PackageCollection([ + new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'), + new Package(Packages::INERTIA_REACT, '@inertiajs/react', '2.1.0'), + ]); + + $this->roster->shouldReceive('packages')->andReturn($packages); + + $composer = Mockery::mock(SkillComposer::class, [$this->roster]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $composer->shouldReceive('resolveFirstPartyBoostPath')->andReturn(null); + + $skills = $composer->skills(); + + expect($skills->has('inertia-react-development'))->toBeTrue(); +}); + test('blade skills with code before frontmatter are parsed correctly', function (): void { $packages = new PackageCollection([ new Package(Packages::LARAVEL, 'laravel/framework', '11.0.0'), diff --git a/tests/Fixtures/.ai/guidelines/laravel/core.blade.php b/tests/Fixtures/.ai/guidelines/laravel/core.blade.php new file mode 100644 index 00000000..2ecfd63e --- /dev/null +++ b/tests/Fixtures/.ai/guidelines/laravel/core.blade.php @@ -0,0 +1,3 @@ +# User Override Laravel Core + +This is the user's override for the Laravel core guideline. diff --git a/tests/Fixtures/vendor-guidelines/core-only/core.blade.php b/tests/Fixtures/vendor-guidelines/core-only/core.blade.php new file mode 100644 index 00000000..b1f89e47 --- /dev/null +++ b/tests/Fixtures/vendor-guidelines/core-only/core.blade.php @@ -0,0 +1,3 @@ +# Vendor Core Guideline + +This guideline was loaded from the vendor directory. diff --git a/tests/Fixtures/vendor-skills/livewire-development/SKILL.md b/tests/Fixtures/vendor-skills/livewire-development/SKILL.md new file mode 100644 index 00000000..4bd40178 --- /dev/null +++ b/tests/Fixtures/vendor-skills/livewire-development/SKILL.md @@ -0,0 +1,8 @@ +--- +name: livewire-development +description: Vendor-overridden Livewire skill +--- + +# Livewire Development (Vendor Override) + +This skill was discovered from a vendor package and should override the built-in .ai/ version. diff --git a/tests/Unit/Install/ThirdPartyPackageTest.php b/tests/Unit/Install/ThirdPartyPackageTest.php index 864cce87..d591e3e5 100644 --- a/tests/Unit/Install/ThirdPartyPackageTest.php +++ b/tests/Unit/Install/ThirdPartyPackageTest.php @@ -44,3 +44,30 @@ 'guidelines only' => [true, false, 'vendor/package (guideline)'], 'skills only' => [false, true, 'vendor/package (skills)'], ]); + +it('excludes first-party packages from discover results', function (): void { + $packages = ThirdPartyPackage::discover(); + + $firstPartyNames = [ + 'laravel/framework', + 'livewire/livewire', + 'pestphp/pest', + 'phpunit/phpunit', + 'laravel/folio', + 'laravel/mcp', + 'laravel/pennant', + 'laravel/pint', + 'laravel/sail', + 'laravel/wayfinder', + 'livewire/flux', + 'livewire/flux-pro', + 'livewire/volt', + 'inertiajs/inertia-laravel', + ]; + + foreach ($firstPartyNames as $name) { + expect($packages->has($name))->toBeFalse( + "First-party package {$name} should be excluded from discover()" + ); + } +}); diff --git a/tests/Unit/Support/NpmTest.php b/tests/Unit/Support/NpmTest.php new file mode 100644 index 00000000..4b598fab --- /dev/null +++ b/tests/Unit/Support/NpmTest.php @@ -0,0 +1,171 @@ +toBe([]); +}); + +it('returns empty packages when package.json is invalid json', function (): void { + file_put_contents(base_path('package.json'), 'invalid json {{{'); + + expect(Npm::packages())->toBe([]); +}); + +it('reads dependencies and devDependencies from package.json', function (): void { + file_put_contents(base_path('package.json'), json_encode([ + 'dependencies' => [ + '@inertiajs/vue3' => '^2.0.0', + ], + 'devDependencies' => [ + 'vite' => '^5.0.0', + ], + ])); + + $packages = Npm::packages(); + + expect($packages) + ->toHaveKey('@inertiajs/vue3') + ->toHaveKey('vite'); +}); + +it('returns empty packages directories when node_modules does not exist', function (): void { + file_put_contents(base_path('package.json'), json_encode([ + 'dependencies' => [ + '@inertiajs/vue3' => '^2.0.0', + ], + ])); + + expect(Npm::packagesDirectories())->toBe([]); +}); + +it('returns package directories that exist in node_modules', function (): void { + file_put_contents(base_path('package.json'), json_encode([ + 'dependencies' => [ + '@inertiajs/vue3' => '^2.0.0', + 'nonexistent-pkg' => '^1.0.0', + ], + ])); + + $dir = base_path('node_modules'.DIRECTORY_SEPARATOR.'@inertiajs'.DIRECTORY_SEPARATOR.'vue3'); + File::ensureDirectoryExists($dir); + + $directories = Npm::packagesDirectories(); + + expect($directories) + ->toHaveKey('@inertiajs/vue3') + ->not->toHaveKey('nonexistent-pkg'); +}); + +it('returns packages directories with boost guidelines', function (): void { + file_put_contents(base_path('package.json'), json_encode([ + 'dependencies' => [ + '@inertiajs/vue3' => '^2.0.0', + '@inertiajs/react' => '^2.0.0', + ], + ])); + + $withGuidelines = base_path(implode(DIRECTORY_SEPARATOR, [ + 'node_modules', '@inertiajs', 'vue3', 'resources', 'boost', 'guidelines', + ])); + File::ensureDirectoryExists($withGuidelines); + + $withoutGuidelines = base_path(implode(DIRECTORY_SEPARATOR, [ + 'node_modules', '@inertiajs', 'react', + ])); + File::ensureDirectoryExists($withoutGuidelines); + + $result = Npm::packagesDirectoriesWithBoostGuidelines(); + + expect($result) + ->toHaveKey('@inertiajs/vue3') + ->not->toHaveKey('@inertiajs/react'); +}); + +it('returns packages directories with boost skills', function (): void { + file_put_contents(base_path('package.json'), json_encode([ + 'dependencies' => [ + '@inertiajs/vue3' => '^2.0.0', + ], + ])); + + $withSkills = base_path(implode(DIRECTORY_SEPARATOR, [ + 'node_modules', '@inertiajs', 'vue3', 'resources', 'boost', 'skills', + ])); + File::ensureDirectoryExists($withSkills); + + $result = Npm::packagesDirectoriesWithBoostSkills(); + + expect($result)->toHaveKey('@inertiajs/vue3'); +}); + +it('handles package.json with no dependencies', function (): void { + file_put_contents(base_path('package.json'), json_encode([ + 'name' => 'test-app', + ])); + + expect(Npm::packages())->toBe([]); +}); + +it('returns non-scoped package directories with boost guidelines', function (): void { + file_put_contents(base_path('package.json'), json_encode([ + 'dependencies' => [ + 'laravel-echo' => '^2.0.0', + 'axios' => '^1.0.0', + ], + ])); + + $withGuidelines = base_path(implode(DIRECTORY_SEPARATOR, [ + 'node_modules', 'laravel-echo', 'resources', 'boost', 'guidelines', + ])); + File::ensureDirectoryExists($withGuidelines); + + $withoutGuidelines = base_path(implode(DIRECTORY_SEPARATOR, [ + 'node_modules', 'axios', + ])); + File::ensureDirectoryExists($withoutGuidelines); + + $result = Npm::packagesDirectoriesWithBoostGuidelines(); + + expect($result) + ->toHaveKey('laravel-echo') + ->not->toHaveKey('axios'); +}); + +it('returns non-scoped package directories with boost skills', function (): void { + file_put_contents(base_path('package.json'), json_encode([ + 'dependencies' => [ + 'laravel-echo' => '^2.0.0', + ], + ])); + + $withSkills = base_path(implode(DIRECTORY_SEPARATOR, [ + 'node_modules', 'laravel-echo', 'resources', 'boost', 'skills', + ])); + File::ensureDirectoryExists($withSkills); + + $result = Npm::packagesDirectoriesWithBoostSkills(); + + expect($result)->toHaveKey('laravel-echo'); +}); + +it('identifies non-scoped first party packages', function (): void { + expect(Npm::isFirstPartyPackage('laravel-echo'))->toBeTrue(); +}); + +it('does not identify unknown packages as first party', function (): void { + expect(Npm::isFirstPartyPackage('axios'))->toBeFalse() + ->and(Npm::isFirstPartyPackage('lodash'))->toBeFalse() + ->and(Npm::isFirstPartyPackage('unknown-package'))->toBeFalse(); +});