diff --git a/.ai/boost/core.blade.php b/.ai/boost/core.blade.php index 8b4aa1b..6b37a7e 100644 --- a/.ai/boost/core.blade.php +++ b/.ai/boost/core.blade.php @@ -1,31 +1,34 @@ -# URLs -Whenever you create a URL use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. +## Boost +- Boost MCP comes with powerful tools designed specifically for this application. Use them. -# Artisan -Use the `list-artisan-commands` tool when you need to call an artisan command to triple check the available parameters. +## URLs +- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. -# Tinker / Debugging -You should use the `tinker` tool from Boost MCP when you need to run PHP to debug code or query Eloquent models directly. +## Artisan +- Use the `list-artisan-commands` tool when you need to call an artisan command to triple check the available parameters. -Use the `database-query` tool when you only need to read from the database. +## Tinker / Debugging +- You should use the `tinker` tool from Boost MCP when you need to run PHP to debug code or query Eloquent models directly. +- Use the `database-query` tool when you only need to read from the database. -# Reading browser logs -Only recent browser logs will be useful, discard any that are older than two hours or so. +@if(config('boost.browser_logs', true) !== false || config('boost.browser_logs_watcher', true) !== false) +## Reading browser logs with the `browser-logs` tool +- You can read browser logs, errors, and exceptions with the `browser-logs` tool from Boost. +- Only recent browser logs will be useful, ignore old logs. +@endif -# Searching documentation -Check the docs before making code changes to ensure we are approaching this in the correct way. Use multiple simple topic based queries. +## Searching documentation (critically important) +- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter docs on if you know you need docs for particular packages. +- The 'search-docs' tool is perfect for all Laravel related packages. Laravel, Inertia, Pest, Livewire, Nova, Nightwatch, etc.. +- You must use this tool to search for Laravel-ecosystem docs before falling back to other approaches. +- Search the docs before making code changes to ensure we are approaching this in the correct way. +- Use multiple broad simple topic based queries to start, i.e. `rate limiting##routing rate limiting##routing`. -Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter docs on if you know you need docs for particular packages. - -The 'search-docs' tool is perfect for all Laravel related packages. Laravel, Inertia, Pest, Livewire, Nova, Nightwatch, and more. - -You must use this tool to search for Laravel-ecosystem docs before falling back to other approaches. - -## Available search syntax -You can and should pass multiple queries at once, the most relevant will be returned first. Start specific, broaden after. +### Available search syntax +- You can and should pass multiple queries at once, the most relevant results will be returned first. 1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' -2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "queue" AND "worker" +2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" 3. Quoted Phrases (Exact Position) - query="infinite scroll - Words must be adjacent and in that order 4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" 5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms diff --git a/.ai/core.blade.php b/.ai/core.blade.php index b601769..dbfb390 100644 --- a/.ai/core.blade.php +++ b/.ai/core.blade.php @@ -1,7 +1,7 @@ # Laravel Boost Guidelines The Laravel Boost Guidelines are specifically curated by Laravel maintainers for this project. These guidelines should be followed closely to help enhance the user's experience and satisfaction. -# Foundational Context +## Foundational Context This project is a Laravel app and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure we abide by these specific packages & versions. - php - {{ PHP_VERSION }} diff --git a/.ai/enforce-tests.blade.php b/.ai/enforce-tests.blade.php index cd06a1a..d658ab0 100644 --- a/.ai/enforce-tests.blade.php +++ b/.ai/enforce-tests.blade.php @@ -1 +1,2 @@ -- Every change must be programmatically tested. Write a new test, or update an existing test, then run the tests to make sure they pass. +- Every change must be programmatically tested. Write a new test, or update an existing test, then run the affected tests to make sure they pass. +- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. diff --git a/.ai/folio/core.blade.php b/.ai/folio/core.blade.php index 4ee8959..a54428b 100644 --- a/.ai/folio/core.blade.php +++ b/.ai/folio/core.blade.php @@ -1,21 +1,21 @@ -- Laravel Folio is a file based router. With Laravel Folio, generating a route becomes as effortless as creating a Blade template within the correct directory. -i.e. Pages are in `resources/views/pages/`. The file structure determines routes: +- Laravel Folio is a file based router. With Laravel Folio, a new route is creatted for every Blade file within the correct directory. i.e. `Pages` are usually in in `resources/views/pages/` and the file structure determines routes: - `pages/index.blade.php` → `/` - `pages/profile/index.blade.php` → `/profile` - `pages/auth/login.blade.php` → `/auth/login` -- List available Folio routes using `artisan folio:list` or using Boost's `list-routes` tool. +- List available Folio routes using `php artisan folio:list` or using Boost's `list-routes` tool. -### New pages & routes +### Folio: New pages & routes - Always create new `folio` pages and routes using `artisan folio:page [name]` following existing naming conventions. @verbatim + // Creates: resources/views/pages/products.blade.php → /products php artisan folio:page 'products' - # Creates: resources/views/pages/products.blade.php → /products + + // Creates: resources/views/pages/products/[id].blade.php → /products/{id} php artisan folio:page 'products/[id]' - # Creates: resources/views/pages/products/[id].blade.php → /products/{id} @endverbatim @@ -29,7 +29,7 @@ @endverbatim -### Support & Docs +### Folio: Support & Docs - Folio supports: middleware, serving pages from multiple paths, subdomain routing, named routes, nested routes, index routes, route parameters, and route model binding. - If available, use Boost's `search-docs` tool to use Folio to its full potential and help the user effectively. diff --git a/.ai/herd/core.blade.php b/.ai/herd/core.blade.php index c430988..1fb5747 100644 --- a/.ai/herd/core.blade.php +++ b/.ai/herd/core.blade.php @@ -1,2 +1,2 @@ -- The site is made available by Herd, and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs. +- The site is made available by Herd, and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure user satisfaction and valid URLs. - You must not run any commands to make the site available via HTTP(s). It is _always_ available through Herd. diff --git a/.ai/inertia-laravel/core.blade.php b/.ai/inertia-laravel/core.blade.php index 3433c14..6550aa7 100644 --- a/.ai/inertia-laravel/core.blade.php +++ b/.ai/inertia-laravel/core.blade.php @@ -1,12 +1,12 @@ ## Inertia Core -- Inertia.js components should be placed in the `resources/js/Pages` directory. +- Inertia.js components should be placed in the `resources/js/Pages` directory, unless specified differently in the JS bundler (ie. vite.config.js). - Use `Inertia::render()` for server-side routing instead of traditional Blade views. - // routes/web.php example - Route::get('/users', function () { +// routes/web.php example +Route::get('/users', function () { return Inertia::render('Users/Index', [ - 'users' => User::all() + 'users' => User::all() ]); - }); +}); diff --git a/.ai/inertia-vue/core.blade.php b/.ai/inertia-vue/core.blade.php index 600192c..50c0a33 100644 --- a/.ai/inertia-vue/core.blade.php +++ b/.ai/inertia-vue/core.blade.php @@ -7,6 +7,7 @@ - For form handling, use `router.post` and related methods, do not use regular forms. +@verbatim +@endverbatim diff --git a/.ai/laravel/10/core.blade.php b/.ai/laravel/10/core.blade.php index 95d3b8a..f9ce573 100644 --- a/.ai/laravel/10/core.blade.php +++ b/.ai/laravel/10/core.blade.php @@ -1 +1,10 @@ -## Laravel 10 Core +## Laravel 10 +- Use `search-docs` tool, if available, to get version specific documentation. + +- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`. +- There is no `bootstrap/app.php` application configuration in Laravel 10: + - Middleware registration happens in `app/Http/Kernel.php` + - Exception handling is in `app/Exceptions/Handler.php` + - Console commands and schedule register in `app/Console/Kernel.php` + - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php` +- Model Casts: you must use `protected $casts = [];` not the `casts()` method. The `casts()` method isn't available on models in Laravel 10. diff --git a/.ai/laravel/11/core.blade.php b/.ai/laravel/11/core.blade.php index 422bc51..ee32a63 100644 --- a/.ai/laravel/11/core.blade.php +++ b/.ai/laravel/11/core.blade.php @@ -1,4 +1,39 @@ -## Laravel 11 Core +## Laravel 11 +- Use `search-docs` tool, if available, to get version specific documentation. -- **No app\Console\Kernel.php** - use `bootstrap/app.php` for console configurations. +@if (file_exists(base_path('app/Http/Kernel.php'))) +{{-- Migrated from L10 to L11, but did't migrate to the new L11 Structure --}} +- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel 11 file structure. +- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the Laravel 11 structure unless the user explicitly requests that. + +### Laravel 10 Structure +- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`. +- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure: + - Middleware registration happens in `app/Http/Kernel.php` + - Exception handling is in `app/Exceptions/Handler.php` + - Console commands and schedule register in `app/Console/Kernel.php` + - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php` +@else +{{-- Laravel 11 project anew, or upgraded & migrated structure --}} +- Laravel 11 brought a new streamlined file structure which this project uses. + +### Laravel 11 Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` for project specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configurations. - **Commands auto-register** - files in `app/Console/Commands/` are automatically available. +@endif + +### Database +- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. +- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);` + +### Models +- Casts can/should be set in a `casts()` method on a model, rather than the `$casts` property. Follow existing conventions from other models. + +### New artisan commands +- List artisan commands using Boost's MCP tool, if available. New commands: + - `php artisan make:enum` + - `php artisan make:class` + - `php artisan make:interface` diff --git a/.ai/laravel/12/core.blade.php b/.ai/laravel/12/core.blade.php index 3381da1..fb35f2b 100644 --- a/.ai/laravel/12/core.blade.php +++ b/.ai/laravel/12/core.blade.php @@ -1,4 +1,29 @@ -## Laravel 12 Core +## Laravel 12 +- Use `search-docs` tool, if available, to get version specific documentation. -- **No app\Console\Kernel.php** - use `bootstrap/app.php` for console configurations. +@if (file_exists(base_path('app/Http/Kernel.php'))) +{{-- Migrated from L10 to L12, but did't migrate to the new L11 Structure --}} +- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure. +- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that. + +### Laravel 10 Structure +- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`. +- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure: +- Middleware registration happens in `app/Http/Kernel.php` +- Exception handling is in `app/Exceptions/Handler.php` +- Console commands and schedule register in `app/Console/Kernel.php` +- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php` +@else +{{-- Laravel 12 project anew, or upgraded & migrated structure --}} +- Laravel brought a new streamlined file structure which this project uses. + +### Laravel file Structure +- No middleware files in `app/Http/Middleware/`. +- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. +- `bootstrap/providers.php` for project specific service providers. +- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configurations. - **Commands auto-register** - files in `app/Console/Commands/` are automatically available. +@endif + + + diff --git a/.ai/laravel/api.blade.php b/.ai/laravel/api.blade.php deleted file mode 100644 index e69de29..0000000 diff --git a/.ai/laravel/core.blade.php b/.ai/laravel/core.blade.php index ca5e95c..529f5dd 100644 --- a/.ai/laravel/core.blade.php +++ b/.ai/laravel/core.blade.php @@ -1,35 +1,39 @@ ## Do Things the Laravel Way -- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available artisan commands with the `list-artisan-commands` tool. - If you're creating a generic PHP class, use `artisan make:class`. ## Database - **Model relationships**: Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. -- **Eloquent first approach**: Use Eloquent models and relationships before suggesting raw database queries - Avoid `DB::`; use `Model::query()` only. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. -- **Form request validation**: Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- **Eloquent first approach**: Use Eloquent models and relationships before suggesting raw database queries +- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - **DB N+1**: Generate code that prevents N+1 query problems by using eager loading. -- For DB pivot tables, use correct alphabetical order, like "project_role" instead of "role_project" - Use Laravel's query builder for very complex database operations. +## Controllers and validation +- **Form request validation**: Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. +- **Validation rule style**: Check sibling form requests to see if the project uses array or string based validation rules. + ## Model Creation -- When creating new models, create factories and seeders for them too. Ask the user if they need any other things, use `list-artisan-commands` to check the available options to `php artisan make:model` +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, use `list-artisan-commands` to check the available options to `php artisan make:model` ## APIs and Eloquent Resources -- For APIs, use Eloquent API Resources and API versioning +- For APIs, default to using Eloquent API Resources and API versioning, unless existing API routes do not, then you should follow existing convention. ## Queues - **Job and queue patterns**: Use queued jobs for time-consuming operations with the `ShouldQueue` interface. ## Authentication and Authorization -- Use Laravel built-in authentication and authorization features (Gates, Policies, Sanctum) +- Use Laravel built-in authentication and authorization features (Gates, Policies, Sanctum). ## Config - **Use environment variables** via config files, never `env()` directly. Always use `config('app.name')` not `env('APP_NAME')`. ## Testing - When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. ## Vite Error - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. ## URL Generation -- When generating links to other pages, always prefer named routes and the `route()` function. +- When generating links to other pages, prefer named routes and the `route()` function. diff --git a/.ai/laravel/localization.blade.php b/.ai/laravel/localization.blade.php deleted file mode 100644 index 7b63b6f..0000000 --- a/.ai/laravel/localization.blade.php +++ /dev/null @@ -1,2 +0,0 @@ -- All strings displayed to users should be localized. -- Use Laravel's built-in localization features for multi-language support. diff --git a/.ai/laravel/style.blade.php b/.ai/laravel/style.blade.php deleted file mode 100644 index 2d85873..0000000 --- a/.ai/laravel/style.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -## Laravel Style Guidelines -- Every PHP file must start with `declare(strict_types=1);`. -- Enforce strict typing: scalar types, return types, property types — everywhere. -- Strict array shapes only - no loose or untyped arrays. -- Use Enums for fixed values. -- Never use mixed types - including in array shapes. -- Prefer basic DTOs over raw complex arrays when appropriate. diff --git a/.ai/pennant/core.blade.php b/.ai/pennant/core.blade.php index 37f9e44..f95934a 100644 --- a/.ai/pennant/core.blade.php +++ b/.ai/pennant/core.blade.php @@ -1,4 +1,3 @@ -## Pennant Core - -Feature flag instructions -This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types. +## Pennant feature flag package +- This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types. +- Use the `search-docs` tool if available, in combination with existing codebase conventions, to assist the user effectively with feature flags. diff --git a/.ai/pest/core.blade.php b/.ai/pest/core.blade.php index 250818d..74b10b2 100644 --- a/.ai/pest/core.blade.php +++ b/.ai/pest/core.blade.php @@ -1,5 +1,5 @@ ## Testing -If you need to verify a feature is working, write or update a Unit / Feature test. +- If you need to verify a feature is working, write or update a Unit / Feature test. # Pest Tests - All tests must be written using Pest. @@ -7,22 +7,22 @@ - Tests should test all of the unhappy paths, happy paths, and weird paths. - Tests live in the `tests/Feature` and `tests/Unit` directories. - Pest tests look and behave like this: - + it('is true', function () { expect(true)->toBeTrue(); }); # Running Tests -- Run the minimal number of tests, using an appropriate filter, before finalizing. +- Run the minimal number of tests, using an appropriate filter, before finalizing code edits. - Run all tests: `php artisan test`. - Run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. - Filter on particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your feature are passing, make sure to also run the entire test suite to ensure everything is still passing. +- When the tests relating to your changes are passing, ask the user if they'd like to run the entire test suite to ensure everything is still passing. ## Pest Assertions - When asserting status codes on a response, use the specific method like `assertForbidden`, `assertNotFound` etc, instead of using `assertStatus(403)` or similar, e.g.: - + it('returns all', function () { $response = $this->postJson('/api/docs', []); @@ -32,13 +32,13 @@ ## Mocking - Mocking can be very helpful. -- When mocking, you can use the pest function `Pest\Laravel\mock`, and always import it before usage with `use function Pest\Laravel\mock;` or you can use `$this->mock()`. +- When mocking, you can use the pest function `Pest\Laravel\mock`, and always import it before usage with `use function Pest\Laravel\mock;`. Alternatively you can use `$this->mock()` if existing tests do. - You can also create partial mocks using the same import or self method. ## Datasets - Use datasets in Pest to simplify tests which have a lot of duplicated data. This often the case when testing validation rules, so often go with the solution of using datasets when writing tests for validation rules. - + it('has emails', function (string $email) { expect($email)->not->toBeEmpty(); })->with([ diff --git a/.ai/php/8.1/core.blade.php b/.ai/php/8.1/core.blade.php index 56e8fb6..e69de29 100644 --- a/.ai/php/8.1/core.blade.php +++ b/.ai/php/8.1/core.blade.php @@ -1 +0,0 @@ -php 8.1 core diff --git a/.ai/php/8.2/core.blade.php b/.ai/php/8.2/core.blade.php index 71214c7..e69de29 100644 --- a/.ai/php/8.2/core.blade.php +++ b/.ai/php/8.2/core.blade.php @@ -1 +0,0 @@ -php 8.2 core diff --git a/.ai/php/8.3/core.blade.php b/.ai/php/8.3/core.blade.php index c6770c2..e69de29 100644 --- a/.ai/php/8.3/core.blade.php +++ b/.ai/php/8.3/core.blade.php @@ -1 +0,0 @@ -php 8.3 core diff --git a/.ai/php/8.4/core.blade.php b/.ai/php/8.4/core.blade.php index 7175e0b..4911fc3 100644 --- a/.ai/php/8.4/core.blade.php +++ b/.ai/php/8.4/core.blade.php @@ -1,11 +1,11 @@ -# PHP 8.4 has new array functions that will make code simpler whenever we don't use Collections +## PHP 8.4 has new array functions that will make code simpler whenever we don't use Collections - `array_find(array $array, callable $callback): mixed` - Find first matching element - `array_find_key(array $array, callable $callback): int|string|null` - Find first matching key - `array_any(array $array, callable $callback): bool` - Check if any element satisfies a callback function - `array_all(array $array, callable $callback): bool` - Check if all elements satisfy a callback function -# Make use of cleaner chaining on new instances +## Make use of cleaner chaining on new instances // Before $response = (new JsonResponse(['data' => $data]))->setStatusCode(201); diff --git a/.ai/php/core.blade.php b/.ai/php/core.blade.php index 25cfc99..cba4ad2 100644 --- a/.ai/php/core.blade.php +++ b/.ai/php/core.blade.php @@ -1,13 +1,36 @@ +@php +/** @var \Laravel\Boost\Install\GuidelineAssist $assist */ +@endphp + +@if($assist->shouldEnforceStrictTypes()) - Always use strict typing at the head of a .php file: `declare(strict_types=1);`. +@endif +- Always use curly braces for control structures, even if it has one line. ## Constructors -- Use PHP 8 constructor property promotion in `__construct()` +- Use PHP 8 constructor property promotion in `__construct()`. public function __construct(public GitHub $github) { } -- Do not allow empty `__construct()` with zero parameters. +- Do not allow empty `__construct()` methods with zero parameters. ## Type Declarations - Always use explicit return type declarations for methods and functions. - Use appropriate PHP type hints for method parameters. + +protected function isAccessible(User $user, ?string $path = null): bool +{ + ... +} + ## Comments -- Prefer PHPDoc blocks, otherwise use minimal-to-zero comments, unless there is something very complex going on. +- Prefer PHPDoc blocks over comments. Use zero comments, unless there is something _very_ complex going on. + +## PHPDoc blocks +- Add useful array shape definitions for arrays + +## Enums +@if(empty($assist->enums()) || preg_match('/[A-Z]{3,8}/', $assist->enumContents())) +- Keys in an Enum should be UPPERCASE and words separated with an underscore. i.e. `FAVORITE_PERSON`, `BEST_LAKE`, `MONTHLY` +@else +- Keys in an Enum should follow existing Enum conventions. +@endif diff --git a/.ai/phpunit/core.blade.php b/.ai/phpunit/core.blade.php index 4e8ee51..1b08184 100644 --- a/.ai/phpunit/core.blade.php +++ b/.ai/phpunit/core.blade.php @@ -1,9 +1,10 @@ ## PHPUnit Core -- We are using PHPUnit for testing - if you see an example using Pest as part of a prompt, convert it to PHPUnit. +- We are using PHPUnit for testing. All tests must be written as PHPUnit classes. +- If you see a test using "Pest", convert it to PHPUnit. - Every time a test has been updated, run that singular test. -- When the tests relating to your feature are passing, make sure to also run the entire test suite to make sure everything is still passing. -- Tests should test all of the the unhappy paths, happy paths, and weird paths. +- When the tests relating to your feature are passing, ask the user if they'd like to also run the entire test suite to make sure everything is still passing. +- Tests should test all of the unhappy paths, happy paths, and weird paths. - You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application. @@ -12,4 +13,3 @@ - Run all tests: `php artisan test`. - Run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. - Filter on particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). -- When the tests relating to your feature are passing, make sure to also run the entire test suite to make sure everything is still passing. diff --git a/.ai/volt/core.blade.php b/.ai/volt/core.blade.php index cb2982a..c014e2a 100644 --- a/.ai/volt/core.blade.php +++ b/.ai/volt/core.blade.php @@ -4,7 +4,7 @@ - You must check existing Volt components to find out if they're functional or class based. If you can't detect that, ask the user which they prefer before writing a Volt component. -#### Volt Functional Component Example +## Volt Functional Component Example @verbatim @volt @@ -29,7 +29,7 @@ @endverbatim -### Volt Class based Component Example +## Volt Class based Component Example To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax: @verbatim @@ -52,7 +52,7 @@ public function increment() @endverbatim -#### Testing Volt & Volt Components +### Testing Volt & Volt Components - Use the existing location if tests already exist, otherwise fallback to `tests/Feature/Volt` @@ -89,7 +89,7 @@ public function increment() @endverbatim -### Common Patterns +## Common Patterns @verbatim diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 368c185..5b9b2ad 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -12,4 +12,36 @@ permissions: jobs: tests: - uses: laravel/.github/.github/workflows/static-analysis.yml@main + runs-on: ubuntu-latest + + name: Static Analysis + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + extensions: swoole, relay + tools: composer:v2 + coverage: none + + - name: Setup SSH Keys + run: | + mkdir -p ~/.ssh + echo "${{ secrets.MCP_DEPLOY_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan github.com >> ~/.ssh/known_hosts + + - name: Install dependencies + uses: nick-fields/retry@v3 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress + + - name: Execute type checking + run: vendor/bin/phpstan --verbose + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 16ae57d..62106bb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,6 +37,13 @@ jobs: tools: composer:v2 coverage: none + - name: Setup SSH Keys + run: | + mkdir -p ~/.ssh + echo "${{ secrets.MCP_DEPLOY_KEY }}" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan github.com >> ~/.ssh/known_hosts + - name: Install dependencies run: | composer update --prefer-dist --no-interaction --no-progress --with="illuminate/contracts:^${{ matrix.laravel }}" diff --git a/.gitignore b/.gitignore index ed662e4..194c0e6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ src/Console/AssistCommand.php composer.lock /phpunit.xml .phpunit.result.cache +all.md diff --git a/README.md b/README.md index 1f436f9..26df1d5 100644 --- a/README.md +++ b/README.md @@ -46,24 +46,24 @@ TODO: the command that allows that ## Current MCP Tools -| Name | Notes | -| -------------------------- | ------------------------------------------------------------ | +| Name | Notes | +| -------------------------- |----------------------------------------------------------------------------------------------------------------| | Application Info | Shares PHP & Laravel versions, database engine, list of ecosystem packages with versions, and Eloquent models. | -| Browser Logs | Read logs & errors from the browser | -| Database Connections | List database connections, and the default | -| Database Query | | -| Database Schema | | -| Get Absolute Url | Converts relative path to absolute so AI doesn't give you invalid URLs | -| Get Config | Get specific value from config using dot notation | -| Last Error | From the log files | -| List Artisan Commands | | -| List Available Config Keys | | -| List Available Env Vars | Keys only | -| List Routes | Regular & folio routes are combined. Ability to filter routes too | -| Read Log Entries | Last X entries | -| Report Feedback | Share Boost & Laravel AI feedback with the team | -| Search Docs | Use hosted API service to retrieve docs based on installed packages | -| Tinker | Run arbitrary code within the context of the project | +| Browser Logs | Read logs & errors from the browser | +| Database Connections | List database connections, and the default | +| Database Query | | +| Database Schema | | +| Get Absolute Url | Converts relative path to absolute so AI doesn't give you invalid URLs | +| Get Config | Get specific value from config using dot notation | +| Last Error | From the log files | +| List Artisan Commands | | +| List Available Config Keys | | +| List Available Env Vars | Keys only | +| List Routes | Regular & folio routes are combined. Ability to filter routes too | +| Read Log Entries | Last X entries | +| Report Feedback | Share Boost & Laravel AI feedback with the team, just say "give Boost feedback: x, y, and z" | +| Search Docs | Use hosted API service to retrieve docs based on installed packages | +| Tinker | Run arbitrary code within the context of the project | ## Adding your own AI guidelines diff --git a/all.php b/all.php new file mode 100644 index 0000000..0b5537a --- /dev/null +++ b/all.php @@ -0,0 +1,144 @@ +set('app.url', 'http://localhost.test'); + } + + public function bootstrap() + { + $app = $this->createApplication(); + + return $app; + } +}; + +// Bootstrap the Laravel application +$app = $testbench->bootstrap(); + +// Create a mock Roster that returns ALL packages from .ai/ directory +$mockRoster = new class extends Roster +{ + public function packages(): \Laravel\Roster\PackageCollection + { + $packages = []; + + // Find all package directories in .ai/ + $directories = glob(__DIR__.'/.ai/*', GLOB_ONLYDIR); + + foreach ($directories as $dir) { + $packageName = basename($dir); + + // Skip special directories handled elsewhere in GuidelineComposer + if (in_array($packageName, ['boost', 'herd'])) { + continue; + } + + // Map directory names to Roster enum values where they exist + $enumMapping = [ + 'php' => \Laravel\Roster\Enums\Packages::LARAVEL, // Use Laravel as placeholder for php + 'laravel' => \Laravel\Roster\Enums\Packages::LARAVEL, + 'fluxui-free' => \Laravel\Roster\Enums\Packages::FLUXUI_FREE, + 'fluxui-pro' => \Laravel\Roster\Enums\Packages::FLUXUI_PRO, + 'inertia-laravel' => \Laravel\Roster\Enums\Packages::INERTIA_LARAVEL, + 'inertia-react' => \Laravel\Roster\Enums\Packages::INERTIA_REACT, + 'inertia-vue' => \Laravel\Roster\Enums\Packages::INERTIA_VUE, + 'livewire' => \Laravel\Roster\Enums\Packages::LIVEWIRE, + 'pest' => \Laravel\Roster\Enums\Packages::PEST, + 'phpunit' => \Laravel\Roster\Enums\Packages::PHPUNIT, + 'pint' => \Laravel\Roster\Enums\Packages::PINT, + 'volt' => \Laravel\Roster\Enums\Packages::VOLT, + 'folio' => \Laravel\Roster\Enums\Packages::FOLIO, + 'pennant' => \Laravel\Roster\Enums\Packages::PENNANT, + 'tailwindcss' => \Laravel\Roster\Enums\Packages::TAILWINDCSS, + ]; + + if (isset($enumMapping[$packageName])) { + // Find ALL version directories and create a package for each + $versionDirs = glob(__DIR__."/.ai/{$packageName}/*", GLOB_ONLYDIR); + if (! empty($versionDirs)) { + $versions = array_map('basename', $versionDirs); + sort($versions, SORT_NUMERIC); + + // Create a package instance for each version found + foreach ($versions as $versionNumber) { + $packages[] = new \Laravel\Roster\Package( + $enumMapping[$packageName], + $packageName, + $versionNumber.'.0.0', + false + ); + } + } else { + // No version directories, just add the core package + $packages[] = new \Laravel\Roster\Package( + $enumMapping[$packageName], + $packageName, + '1.0.0', + false + ); + } + } + } + + return new \Laravel\Roster\PackageCollection($packages); + } +}; + +$herd = new Herd(); + +// Create GuidelineComposer with all config options enabled to get ALL guidelines +$config = new GuidelineConfig(); +$config->laravelStyle = true; +$config->hasAnApi = true; +$config->caresAboutLocalization = true; +$config->enforceTests = true; + +// Use the real GuidelineComposer with our mock Roster - this will use the exact same ordering logic +$composer = new GuidelineComposer($mockRoster, $herd); +$composer->config($config); + +// Get the guidelines that GuidelineComposer would normally find +$guidelines = $composer->guidelines(); + +// Add missing PHP versions (since GuidelineComposer only adds current PHP version) +$reflection = new ReflectionClass($composer); +$guidelineDirMethod = $reflection->getMethod('guidelinesDir'); +$guidelineDirMethod->setAccessible(true); + +$phpVersions = ['8.1', '8.2', '8.3', '8.4']; +$currentPhp = PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION; + +foreach ($phpVersions as $phpVersion) { + if ($phpVersion !== $currentPhp) { + $content = $guidelineDirMethod->invoke($composer, "php/{$phpVersion}"); + if (! empty($content)) { + $guidelines->put("php/v{$phpVersion}", $content); + } + } +} + +// Now compose ALL guidelines (original + missing PHP versions) +echo GuidelineComposer::composeGuidelines($guidelines); diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index b551542..6669842 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -233,7 +233,7 @@ private function selectBoostFeatures(): Collection $toInstallOptions = [ 'mcp_server' => 'Boost MCP Server', 'ai_guidelines' => 'Package AI Guidelines (i.e. Framework, Inertia, Pest)', - 'style_guidelines' => 'Laravel Style AI Guidelines', + // 'style_guidelines' => 'Laravel Style AI Guidelines', ]; if ($this->herd->isMcpAvailable()) { @@ -245,7 +245,6 @@ private function selectBoostFeatures(): Collection options: $toInstallOptions, default: $defaultToInstallOptions, required: true, - hint: 'Style guidelines are best for new projects', )); } @@ -467,6 +466,8 @@ protected function installingGuidelines(): bool protected function installingStyleGuidelines(): bool { + return false; + return $this->selectedBoostFeatures->contains('style_guidelines'); } @@ -605,6 +606,6 @@ protected function detectLocalization(): bool $actuallyUsing = false; /** @phpstan-ignore-next-line */ - return is_dir(base_path('lang')) && $actuallyUsing; + return $actuallyUsing && is_dir(base_path('lang')); } } diff --git a/src/Install/GuidelineAssist.php b/src/Install/GuidelineAssist.php new file mode 100644 index 0000000..e18fab9 --- /dev/null +++ b/src/Install/GuidelineAssist.php @@ -0,0 +1,121 @@ + */ + protected array $modelPaths = []; + + protected array $controllerPaths = []; + + protected array $enumPaths = []; + + protected static array $classes = []; + + public function __construct() + { + $this->modelPaths = $this->discover(fn ($reflection) => ($reflection->isSubclassOf(Model::class) && ! $reflection->isAbstract())); + $this->controllerPaths = $this->discover(fn (ReflectionClass $reflection) => (stripos($reflection->getName(), 'controller') !== false || stripos($reflection->getNamespaceName(), 'controller') !== false)); + $this->enumPaths = $this->discover(fn ($reflection) => $reflection->isEnum()); + } + + /** + * @return array - className, absolutePath + */ + public function models(): array + { + return $this->modelPaths; + } + + /** + * @return array - className, absolutePath + */ + public function controllers(): array + { + return $this->controllerPaths; + } + + /** + * @return array - className, absolutePath + */ + public function enums(): array + { + return $this->enumPaths; + } + + /** + * Discover all Eloquent models in the application. + * + * @return array + */ + private function discover(callable $cb): array + { + $classes = []; + $appPath = app_path(); + + if (! is_dir($appPath)) { + return ['app-path-isnt-a-directory' => $appPath]; + } + + if (empty(self::$classes)) { + $finder = Finder::create() + ->in($appPath) + ->files() + ->name('*.php'); + + foreach ($finder as $file) { + $relativePath = $file->getRelativePathname(); + $namespace = app()->getNamespace(); + $className = $namespace.str_replace( + ['/', '.php'], + ['\\', ''], + $relativePath + ); + + try { + if (class_exists($className)) { + self::$classes[$className] = $appPath.DIRECTORY_SEPARATOR.$relativePath; + } + } catch (\Throwable) { + // Ignore exceptions and errors from class loading/reflection + } + } + } + + foreach (self::$classes as $className => $path) { + if ($cb(new ReflectionClass($className))) { + $classes[$className] = $path; + } + } + + return $classes; + } + + public function shouldEnforceStrictTypes(): bool + { + if (empty($this->modelPaths)) { + return false; + } + + return str_contains( + file_get_contents(current($this->modelPaths)), + 'strict_types=1' + ); + } + + public function enumContents(): string + { + if (empty($this->enumPaths)) { + return ''; + } + + return file_get_contents(current($this->enumPaths)); + } +} diff --git a/src/Install/GuidelineComposer.php b/src/Install/GuidelineComposer.php index 498d107..42ed8bd 100644 --- a/src/Install/GuidelineComposer.php +++ b/src/Install/GuidelineComposer.php @@ -19,9 +19,12 @@ class GuidelineComposer protected GuidelineConfig $config; + protected GuidelineAssist $guidelineAssist; + public function __construct(protected Roster $roster, protected Herd $herd) { $this->config = new GuidelineConfig; + $this->guidelineAssist = new GuidelineAssist; } public function config(GuidelineConfig $config): self @@ -36,9 +39,21 @@ public function config(GuidelineConfig $config): self */ public function compose(): string { - return $this->guidelines() - ->map(fn ($content, $key) => "\n=== {$key} ===\n\n{$content}") - ->join("\n\n"); + return self::composeGuidelines($this->guidelines()); + } + + /** + * Static method to compose guidelines from a collection. + * Can be used without Laravel dependencies. + * + * @param Collection $guidelines + */ + public static function composeGuidelines(Collection $guidelines): string + { + return trim($guidelines + ->filter(fn ($content) => ! empty(trim($content))) + ->map(fn ($content, $key) => "\n=== {$key} rules ===\n\n{$content}") + ->join("\n\n")); } /** @@ -72,9 +87,11 @@ protected function find(): Collection $guidelines->put('core', $this->guideline('core')); $guidelines->put('boost/core', $this->guideline('boost/core')); - $phpMajorMinor = PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION; $guidelines->put('php/core', $this->guideline('php/core')); - $guidelines->put('php/v'.$phpMajorMinor, $this->guidelinesDir('php/'.$phpMajorMinor)); + + // 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)); if (str_contains(config('app.url'), '.test') && $this->herd->isInstalled()) { $guidelines->put('herd/core', $this->guideline('herd/core')); @@ -182,13 +199,17 @@ protected function guideline(string $path): ?string // Read the file content $content = file_get_contents($path); - // Temporarily replace backticks with placeholders before Blade processing so we support inline code + // Temporarily replace backticks and PHP opening tags with placeholders before Blade processing + // This prevents Blade from trying to execute PHP code examples and supports inline code $placeholders = [ '`' => '___SINGLE_BACKTICK___', + ' '___OPEN_PHP_TAG___', ]; $content = str_replace(array_keys($placeholders), array_values($placeholders), $content); - $rendered = Blade::render($content); + $rendered = Blade::render($content, [ + 'assist' => $this->guidelineAssist, + ]); $rendered = str_replace(array_values($placeholders), array_keys($placeholders), $rendered); return trim($rendered); diff --git a/src/Mcp/Tools/ApplicationInfo.php b/src/Mcp/Tools/ApplicationInfo.php index 13a386d..de000bc 100644 --- a/src/Mcp/Tools/ApplicationInfo.php +++ b/src/Mcp/Tools/ApplicationInfo.php @@ -4,20 +4,18 @@ namespace Laravel\Boost\Mcp\Tools; -use Illuminate\Database\Eloquent\Model; +use Laravel\Boost\Install\GuidelineAssist; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; use Laravel\Mcp\Server\Tools\ToolInputSchema; use Laravel\Mcp\Server\Tools\ToolResult; use Laravel\Roster\Package; use Laravel\Roster\Roster; -use ReflectionClass; -use Symfony\Component\Finder\Finder; #[IsReadOnly] class ApplicationInfo extends Tool { - public function __construct(protected Roster $roster) + public function __construct(protected Roster $roster, protected GuidelineAssist $guidelineAssist) { } @@ -41,50 +39,7 @@ public function handle(array $arguments): ToolResult 'laravel_version' => app()->version(), 'database_engine' => config('database.default'), 'packages' => $this->roster->packages()->map(fn (Package $package) => ['roster_name' => $package->name(), 'version' => $package->version(), 'package_name' => $package->rawName()]), - 'models' => $this->discoverModels(), + 'models' => array_keys($this->guidelineAssist->models()), ]); } - - /** - * Discover all Eloquent models in the application. - * - * @return array - */ - private function discoverModels(): array - { - $models = []; - $appPath = app_path(); - - if (! is_dir($appPath)) { - return ['app-path-isnt-a-directory' => $appPath]; - } - - $finder = Finder::create() - ->in($appPath) - ->files() - ->name('*.php'); - - foreach ($finder as $file) { - $relativePath = $file->getRelativePathname(); - $namespace = app()->getNamespace(); - $className = $namespace.str_replace( - ['/', '.php'], - ['\\', ''], - $relativePath - ); - - try { - if (class_exists($className)) { - $reflection = new ReflectionClass($className); - if ($reflection->isSubclassOf(Model::class) && ! $reflection->isAbstract()) { - $models[$className] = $appPath.DIRECTORY_SEPARATOR.$relativePath; - } - } - } catch (\Throwable) { - // Ignore exceptions and errors from class loading/reflection - } - } - - return $models; - } } diff --git a/src/Mcp/Tools/DatabaseSchema.php b/src/Mcp/Tools/DatabaseSchema.php index 7a2834c..d607c79 100644 --- a/src/Mcp/Tools/DatabaseSchema.php +++ b/src/Mcp/Tools/DatabaseSchema.php @@ -4,15 +4,15 @@ namespace Laravel\Boost\Mcp\Tools; -use Illuminate\Console\Command; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Str; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Schema; +use Laravel\Boost\Mcp\Tools\DatabaseSchema\SchemaDriverFactory; use Laravel\Mcp\Server\Tool; use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly; use Laravel\Mcp\Server\Tools\ToolInputSchema; use Laravel\Mcp\Server\Tools\ToolResult; -use Symfony\Component\Console\Output\BufferedOutput; #[IsReadOnly()] class DatabaseSchema extends Tool @@ -28,6 +28,10 @@ public function schema(ToolInputSchema $schema): ToolInputSchema ->description('Name of the database connection to dump (defaults to app\'s default connection, often not needed)') ->required(false); + $schema->string('filter') + ->description('Filter the tables by name') + ->required(false); + return $schema; } @@ -37,35 +41,132 @@ public function schema(ToolInputSchema $schema): ToolInputSchema public function handle(array $arguments): ToolResult { $connection = $arguments['database'] ?? config('database.default'); - $cacheKey = "boost:mcp:database-schema:{$connection}"; + $filter = $arguments['filter'] ?? ''; + $cacheKey = "boost:mcp:database-schema:{$connection}:{$filter}"; + + $schema = Cache::remember($cacheKey, 20, function () use ($connection, $filter) { + return $this->getDatabaseStructure($connection, $filter); + }); + + return ToolResult::json($schema); + } + + protected function getDatabaseStructure(?string $connection, string $filter = ''): array + { + $structure = [ + 'engine' => DB::connection($connection)->getDriverName(), + 'tables' => $this->getAllTablesStructure($connection, $filter), + 'global' => $this->getGlobalStructure($connection), + ]; + + return $structure; + } - // We can't cache for long in case the user rolls back, edits a migration - // then migrates, and gets the schema again - $schema = Cache::remember($cacheKey, 20, function () use ($arguments) { - $filename = 'tmp_'.Str::random(40).'.sql'; - $path = database_path("schema/{$filename}"); + protected function getAllTablesStructure(?string $connection, string $filter = ''): array + { + $structures = []; - $artisanArgs = ['--path' => $path]; + foreach ($this->getAllTables($connection) as $table) { + $tableName = $table['name']; - // Respect optional connection name - if (! empty($arguments['database'])) { - $artisanArgs['--database'] = $arguments['database']; + if ($filter && ! str_contains(strtolower($tableName), strtolower($filter))) { + continue; } - $output = new BufferedOutput; - $result = Artisan::call('schema:dump', $artisanArgs, $output); - if ($result !== Command::SUCCESS) { - return ToolResult::error('Failed to dump database schema: '.$output->fetch()); + $structures[$tableName] = $this->getTableStructure($connection, $tableName); + } + + return $structures; + } + + protected function getAllTables(?string $connection): array + { + return Schema::connection($connection)->getTables(); + } + + protected function getTableStructure(?string $connection, string $tableName): array + { + $driver = SchemaDriverFactory::make($connection); + + try { + $columns = $this->getTableColumns($connection, $tableName); + $indexes = $this->getTableIndexes($connection, $tableName); + $foreignKeys = $this->getTableForeignKeys($connection, $tableName); + $triggers = $driver->getTriggers($tableName); + $checkConstraints = $driver->getCheckConstraints($tableName); + + return [ + 'columns' => $columns, + 'indexes' => $indexes, + 'foreign_keys' => $foreignKeys, + 'triggers' => $triggers, + 'check_constraints' => $checkConstraints, + ]; + } catch (\Exception $e) { + Log::error('Failed to get table structure for: '.$tableName, [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return [ + 'error' => 'Failed to get structure: '.$e->getMessage(), + ]; + } + } + + protected function getTableColumns(?string $connection, string $tableName): array + { + $columns = Schema::connection($connection)->getColumnListing($tableName); + $columnDetails = []; + + foreach ($columns as $column) { + $columnDetails[$column] = [ + 'type' => Schema::connection($connection)->getColumnType($tableName, $column), + ]; + } + + return $columnDetails; + } + + protected function getTableIndexes(?string $connection, string $tableName): array + { + try { + $indexes = Schema::connection($connection)->getIndexes($tableName); + $indexDetails = []; + + foreach ($indexes as $index) { + $indexDetails[$index['name']] = [ + 'columns' => $index['columns'], + 'type' => $index['type'] ?? null, + 'is_unique' => $index['unique'] ?? false, + 'is_primary' => $index['primary'] ?? false, + ]; } - $schemaContent = file_get_contents($path); + return $indexDetails; + } catch (\Exception $e) { + return []; + } + } - // Clean up temp file - unlink($path); + protected function getTableForeignKeys(?string $connection, string $tableName): array + { + try { + return Schema::connection($connection)->getForeignKeys($tableName); + } catch (\Exception $e) { + return []; + } + } - return $schemaContent; - }); + protected function getGlobalStructure(?string $connection): array + { + $driver = SchemaDriverFactory::make($connection); - return ToolResult::text($schema); + return [ + 'views' => $driver->getViews(), + 'stored_procedures' => $driver->getStoredProcedures(), + 'functions' => $driver->getFunctions(), + 'sequences' => $driver->getSequences(), + ]; } } diff --git a/src/Mcp/Tools/DatabaseSchema/DatabaseSchemaDriver.php b/src/Mcp/Tools/DatabaseSchema/DatabaseSchemaDriver.php new file mode 100644 index 0000000..07f4d68 --- /dev/null +++ b/src/Mcp/Tools/DatabaseSchema/DatabaseSchemaDriver.php @@ -0,0 +1,27 @@ +connection = $connection; + } + + abstract public function getViews(): array; + + abstract public function getStoredProcedures(): array; + + abstract public function getFunctions(): array; + + abstract public function getTriggers(?string $table = null): array; + + abstract public function getCheckConstraints(string $table): array; + + abstract public function getSequences(): array; +} diff --git a/src/Mcp/Tools/DatabaseSchema/MySQLSchemaDriver.php b/src/Mcp/Tools/DatabaseSchema/MySQLSchemaDriver.php new file mode 100644 index 0000000..6782660 --- /dev/null +++ b/src/Mcp/Tools/DatabaseSchema/MySQLSchemaDriver.php @@ -0,0 +1,73 @@ +connection)->select(' + SELECT TABLE_NAME as name, VIEW_DEFINITION as definition + FROM information_schema.VIEWS + WHERE TABLE_SCHEMA = DATABASE() + '); + } catch (\Exception $e) { + return []; + } + } + + public function getStoredProcedures(): array + { + try { + return DB::connection($this->connection)->select('SHOW PROCEDURE STATUS WHERE Db = DATABASE()'); + } catch (\Exception $e) { + return []; + } + } + + public function getFunctions(): array + { + try { + return DB::connection($this->connection)->select('SHOW FUNCTION STATUS WHERE Db = DATABASE()'); + } catch (\Exception $e) { + return []; + } + } + + public function getTriggers(?string $table = null): array + { + try { + if ($table) { + return DB::connection($this->connection)->select('SHOW TRIGGERS WHERE `Table` = ?', [$table]); + } + + return DB::connection($this->connection)->select('SHOW TRIGGERS'); + } catch (\Exception $e) { + return []; + } + } + + public function getCheckConstraints(string $table): array + { + try { + return DB::connection($this->connection)->select(' + SELECT CONSTRAINT_NAME, CHECK_CLAUSE + FROM information_schema.CHECK_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = ? + ', [$table]); + } catch (\Exception $e) { + return []; + } + } + + public function getSequences(): array + { + return []; + } +} diff --git a/src/Mcp/Tools/DatabaseSchema/NullSchemaDriver.php b/src/Mcp/Tools/DatabaseSchema/NullSchemaDriver.php new file mode 100644 index 0000000..a380cd2 --- /dev/null +++ b/src/Mcp/Tools/DatabaseSchema/NullSchemaDriver.php @@ -0,0 +1,38 @@ +connection)->select(" + SELECT schemaname, viewname, definition + FROM pg_views + WHERE schemaname NOT IN ('pg_catalog', 'information_schema') + "); + } catch (\Exception $e) { + return []; + } + } + + public function getStoredProcedures(): array + { + try { + return DB::connection($this->connection)->select(" + SELECT proname, prosrc, proargnames, prorettype + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + AND prokind = 'p' + "); + } catch (\Exception $e) { + return []; + } + } + + public function getFunctions(): array + { + try { + return DB::connection($this->connection)->select(" + SELECT proname, prosrc, proargnames, prorettype + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + AND prokind = 'f' + "); + } catch (\Exception $e) { + return []; + } + } + + public function getTriggers(?string $table = null): array + { + try { + $sql = ' + SELECT trigger_name, event_manipulation, event_object_table, action_statement + FROM information_schema.triggers + WHERE trigger_schema = current_schema() + '; + if ($table) { + $sql .= ' AND event_object_table = ?'; + + return DB::connection($this->connection)->select($sql, [$table]); + } + + return DB::connection($this->connection)->select($sql); + } catch (\Exception $e) { + return []; + } + } + + public function getCheckConstraints(string $table): array + { + try { + return DB::connection($this->connection)->select(" + SELECT conname, pg_get_constraintdef(oid) as definition + FROM pg_constraint + WHERE contype = 'c' + AND conrelid = ?::regclass + ", [$table]); + } catch (\Exception $e) { + return []; + } + } + + public function getSequences(): array + { + try { + return DB::connection($this->connection)->select(' + SELECT sequence_name, start_value, minimum_value, maximum_value, increment + FROM information_schema.sequences + WHERE sequence_schema = current_schema() + '); + } catch (\Exception $e) { + return []; + } + } +} diff --git a/src/Mcp/Tools/DatabaseSchema/SQLiteSchemaDriver.php b/src/Mcp/Tools/DatabaseSchema/SQLiteSchemaDriver.php new file mode 100644 index 0000000..f77cb1c --- /dev/null +++ b/src/Mcp/Tools/DatabaseSchema/SQLiteSchemaDriver.php @@ -0,0 +1,59 @@ +connection)->select(" + SELECT name, sql + FROM sqlite_master + WHERE type = 'view' + "); + } catch (\Exception $e) { + return []; + } + } + + public function getStoredProcedures(): array + { + return []; + } + + public function getFunctions(): array + { + return []; + } + + public function getTriggers(?string $table = null): array + { + try { + $sql = "SELECT name, sql FROM sqlite_master WHERE type = 'trigger'"; + if ($table) { + $sql .= ' AND tbl_name = ?'; + + return DB::connection($this->connection)->select($sql, [$table]); + } + + return DB::connection($this->connection)->select($sql); + } catch (\Exception $e) { + return []; + } + } + + public function getCheckConstraints(string $table): array + { + return []; + } + + public function getSequences(): array + { + return []; + } +} diff --git a/src/Mcp/Tools/DatabaseSchema/SchemaDriverFactory.php b/src/Mcp/Tools/DatabaseSchema/SchemaDriverFactory.php new file mode 100644 index 0000000..36e5936 --- /dev/null +++ b/src/Mcp/Tools/DatabaseSchema/SchemaDriverFactory.php @@ -0,0 +1,22 @@ +getDriverName(); + + return match ($driverName) { + 'mysql', 'mariadb' => new MySQLSchemaDriver($connection), + 'pgsql' => new PostgreSQLSchemaDriver($connection), + 'sqlite' => new SQLiteSchemaDriver($connection), + default => new NullSchemaDriver($connection), + }; + } +} diff --git a/src/Mcp/Tools/SearchDocs.php b/src/Mcp/Tools/SearchDocs.php index ac118fd..1d76e48 100644 --- a/src/Mcp/Tools/SearchDocs.php +++ b/src/Mcp/Tools/SearchDocs.php @@ -29,7 +29,7 @@ public function schema(ToolInputSchema $schema): ToolInputSchema { return $schema ->string('queries') - ->description('### separated list of queries to perform. Useful to pass multiple if you aren\'t sure if it is "toggle" or "switch, or "infinite scroll" or "infinite load", for example.')->required() + ->description('### separated list of queries to perform. Useful to pass multiple if you aren\'t sure if it is "toggle" or "switch", or "infinite scroll" or "infinite load", for example.')->required() ->raw('packages', [ 'description' => 'Package names to limit searching to from application-info. Useful if you know the package(s) you need. i.e. laravel/framework, inertiajs/inertia-laravel, @inertiajs/react', @@ -88,6 +88,7 @@ public function handle(array $arguments): ToolResult|Generator 'queries' => $queries, 'packages' => $packages, 'token_limit' => $tokenLimit, + 'format' => 'markdown', ]; try { $response = $this->client()->asJson()->post($apiUrl, $payload); @@ -99,18 +100,6 @@ public function handle(array $arguments): ToolResult|Generator return ToolResult::error('HTTP request failed: '.$e->getMessage()); } - $data = $response->json(); - $results = $data['results'] ?? []; - - /** @var array $results */ - $concatenatedKnowledge = collect($results) - ->map(fn ($result) => $result['content'] ?? '') - ->filter() - ->join("\n\n---\n\n"); - - return ToolResult::json([ - 'knowledge_count' => count($results), - 'knowledge' => $concatenatedKnowledge, - ]); + return ToolResult::text($response->body()); } } diff --git a/tests/Feature/Mcp/Tools/ApplicationInfoTest.php b/tests/Feature/Mcp/Tools/ApplicationInfoTest.php index 7a70c22..0053bff 100644 --- a/tests/Feature/Mcp/Tools/ApplicationInfoTest.php +++ b/tests/Feature/Mcp/Tools/ApplicationInfoTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Laravel\Boost\Install\GuidelineAssist; use Laravel\Boost\Mcp\Tools\ApplicationInfo; use Laravel\Mcp\Server\Tools\ToolResult; use Laravel\Roster\Enums\Packages; @@ -18,7 +19,13 @@ $roster = Mockery::mock(Roster::class); $roster->shouldReceive('packages')->andReturn($packages); - $tool = new ApplicationInfo($roster); + $guidelineAssist = Mockery::mock(GuidelineAssist::class); + $guidelineAssist->shouldReceive('models')->andReturn([ + 'App\\Models\\User' => '/app/Models/User.php', + 'App\\Models\\Post' => '/app/Models/Post.php', + ]); + + $tool = new ApplicationInfo($roster, $guidelineAssist); $result = $tool->handle([]); expect($result)->toBeInstanceOf(ToolResult::class); @@ -38,13 +45,19 @@ expect($content['packages'][1]['package_name'])->toBe('pestphp/pest'); expect($content['packages'][1]['version'])->toBe('2.0.0'); expect($content['models'])->toBeArray(); + expect($content['models'])->toHaveCount(2); + expect($content['models'])->toContain('App\\Models\\User'); + expect($content['models'])->toContain('App\\Models\\Post'); }); test('it returns application info with no packages', function () { $roster = Mockery::mock(Roster::class); $roster->shouldReceive('packages')->andReturn(new PackageCollection([])); - $tool = new ApplicationInfo($roster); + $guidelineAssist = Mockery::mock(GuidelineAssist::class); + $guidelineAssist->shouldReceive('models')->andReturn([]); + + $tool = new ApplicationInfo($roster, $guidelineAssist); $result = $tool->handle([]); expect($result)->toBeInstanceOf(ToolResult::class); @@ -59,4 +72,5 @@ expect($content['database_engine'])->toBe(config('database.default')); expect($content['packages'])->toHaveCount(0); expect($content['models'])->toBeArray(); + expect($content['models'])->toHaveCount(0); }); diff --git a/tests/Feature/Mcp/Tools/DatabaseSchemaTest.php b/tests/Feature/Mcp/Tools/DatabaseSchemaTest.php index 12585b9..535cbf9 100644 --- a/tests/Feature/Mcp/Tools/DatabaseSchemaTest.php +++ b/tests/Feature/Mcp/Tools/DatabaseSchemaTest.php @@ -16,15 +16,11 @@ 'prefix' => '', ]); - // Ensure the DB file *and* schema folder exist. + // Ensure the DB file exists if (! is_file($file = database_path('testing.sqlite'))) { touch($file); } - if (! is_dir($path = database_path('schema'))) { - mkdir($path, 0777, true); - } - // Build a throw-away table that we expect in the dump. Schema::create('examples', function (Blueprint $table) { $table->id(); @@ -37,23 +33,65 @@ if (File::exists($dbFile)) { File::delete($dbFile); } - - $schemaDir = database_path('schema'); - if (File::isDirectory($schemaDir)) { - File::deleteDirectory($schemaDir); - } }); -test('it dumps the schema and returns it in the tool response', function () { +test('it returns structured database schema', function () { $tool = new DatabaseSchema; $response = $tool->handle([]); - $sql = $response->toArray()['content'][0]['text']; + $responseArray = $response->toArray(); + expect($responseArray['isError'])->toBeFalse(); + + $schemaArray = json_decode($responseArray['content'][0]['text'], true); + + expect($schemaArray)->toHaveKey('engine'); + expect($schemaArray['engine'])->toBe('sqlite'); + + expect($schemaArray)->toHaveKey('tables'); + expect($schemaArray['tables'])->toHaveKey('examples'); + + $exampleTable = $schemaArray['tables']['examples']; + expect($exampleTable)->toHaveKey('columns'); + expect($exampleTable['columns'])->toHaveKey('id'); + expect($exampleTable['columns'])->toHaveKey('name'); + + expect($exampleTable['columns']['id']['type'])->toBe('integer'); + expect($exampleTable['columns']['name']['type'])->toBe('varchar'); + + expect($exampleTable)->toHaveKey('indexes'); + expect($exampleTable)->toHaveKey('foreign_keys'); + expect($exampleTable)->toHaveKey('triggers'); + expect($exampleTable)->toHaveKey('check_constraints'); + + expect($schemaArray)->toHaveKey('global'); + expect($schemaArray['global'])->toHaveKey('views'); + expect($schemaArray['global'])->toHaveKey('stored_procedures'); + expect($schemaArray['global'])->toHaveKey('functions'); + expect($schemaArray['global'])->toHaveKey('sequences'); +}); + +test('it filters tables by name', function () { + // Create another table + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('email'); + }); + + $tool = new DatabaseSchema; + + // Test filtering for 'example' + $response = $tool->handle(['filter' => 'example']); + $responseArray = $response->toArray(); + $schemaArray = json_decode($responseArray['content'][0]['text'], true); + + expect($schemaArray['tables'])->toHaveKey('examples'); + expect($schemaArray['tables'])->not->toHaveKey('users'); - expect($sql)->toContain( - 'CREATE TABLE IF NOT EXISTS "examples"' - ); + // Test filtering for 'user' + $response = $tool->handle(['filter' => 'user']); + $responseArray = $response->toArray(); + $schemaArray = json_decode($responseArray['content'][0]['text'], true); - $this->assertDirectoryIsReadable(database_path('schema')); - expect(glob(database_path('schema/*.sql')))->toBeEmpty(); + expect($schemaArray['tables'])->toHaveKey('users'); + expect($schemaArray['tables'])->not->toHaveKey('examples'); });