diff --git a/.claude/pr-review-process.md b/.claude/pr-review-process.md index 0a51f38c..630410b4 100644 --- a/.claude/pr-review-process.md +++ b/.claude/pr-review-process.md @@ -40,15 +40,19 @@ If there are conflicts, resolve them. If the rebase is clean, proceed. ## 4. Run the test suite and lint ```bash +composer ci # everything the CI gate runs composer test # full suite (excludes integration-destructive) ./vendor/bin/pest packages//tests # package-specific tests ./vendor/bin/phpcs --standard=phpcs.xml packages// # phpcs ./vendor/bin/php-cs-fixer fix packages// --dry-run # cs-fixer +composer phpstan # static analysis ``` -All tests must pass and lint must be clean. Fix ALL lint errors in touched files before merging, including pre-existing ones. +All tests must pass, lint must be clean, and **PHPStan must report zero errors**. Fix ALL lint errors in touched files before merging, including pre-existing ones. -**No PR CI workflow exists in this repo.** `gh pr checks ` will report no checks. The merge-readiness signal is local lint + tests passing plus `gh pr view --json mergeStateStatus,mergeable` returning `CLEAN`/`MERGEABLE`. There is no async CI to wait for — do not write "will merge once CI is green" in PR comments. +**PHPStan is not optional and is not covered by `composer test`.** It went unrun for months precisely because this checklist omitted it, and `packages/core` accumulated 12 errors through normal PR review — type-level drift that PHP tolerates at runtime, so no test could have caught it. `composer ci` runs the whole gate in one command; prefer it. + +**The `CI` workflow gates every PR.** `gh pr checks ` reports `Tests`, `Lint`, and `Static analysis`. A red check blocks the merge — `develop` is never allowed to carry a failing test, lint error, or PHPStan error. The `integration-destructive` group is excluded from the gate (it deletes `vendor/`, runs `composer update`, and re-runs the suite in a subprocess); it runs on the `Nightly` schedule and again inside `bin/release.sh` before any tag is cut. ## 5. Review the code @@ -241,6 +245,8 @@ Before merging any package PR: - [ ] `composer test` passes - [ ] `./vendor/bin/phpcs` clean on touched files - [ ] `./vendor/bin/php-cs-fixer fix --dry-run` clean on touched files +- [ ] `composer phpstan` reports zero errors +- [ ] `gh pr checks ` green — `Tests`, `Lint`, `Static analysis` all passing - [ ] New code has corresponding tests - [ ] No hardcoded paths or environment-specific values - [ ] No `final` classes (including exceptions) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6c83a094 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + pull_request: + push: + branches: + - develop + - main + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + coverage: none + ini-values: memory_limit=2G + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + # Excludes integration-destructive: that group deletes vendor/ and composer.lock, + # runs `composer update`, and re-runs the whole suite in a subprocess. It verifies + # install integrity rather than application behaviour, so it runs on the nightly + # schedule below and is enforced again by bin/release.sh before any tag is cut. + - name: Run test suite + run: composer test + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: PHP_CodeSniffer + run: vendor/bin/phpcs --standard=phpcs.xml + + - name: PHP CS Fixer + run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --dry-run --diff + + static: + name: Static analysis + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + coverage: none + ini-values: memory_limit=2G + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: PHPStan + run: composer phpstan diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 00000000..5447b398 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,31 @@ +name: Nightly + +# The integration-destructive group deletes vendor/ and composer.lock, runs a full +# `composer update`, and re-runs the suite in a subprocess. That is too slow and too +# stateful for a per-PR gate, but it must not go unrun between releases — bin/release.sh +# is otherwise the first thing to discover a broken install, at the worst moment. +on: + schedule: + - cron: '0 9 * * *' + workflow_dispatch: + +jobs: + destructive: + name: Full suite including destructive integration + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + coverage: none + ini-values: memory_limit=2G + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Run full test suite + run: composer test:all diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 79864550..98e32aed 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -17,6 +17,13 @@ ]) ->exclude('vendor') ->exclude('.phpunit.cache') + // Fixtures that are invalid PHP on purpose. The config and codeindexer packages test how + // they behave when handed an unparseable file, so these cannot be made valid — that is the + // test case. php-cs-fixer lints before fixing and exits non-zero on them even when it has + // nothing to change, which would fail the Lint job on a permanently red herring. + ->notPath('config/tests/Unit/fixtures/syntax-error.php') + ->notPath('codeindexer/tests/Fixtures/AttributeFixtures/src/Broken/SyntaxError.php') + ->notPath('codeindexer/tests/Fixtures/ConfigFixtures/module-d/config/broken.php') ->name('*.php') ->ignoreDotFiles(true) ->ignoreVCS(true); diff --git a/CLAUDE.md b/CLAUDE.md index 74c93cf9..3aa5b7dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,8 +27,16 @@ composer test:all # Lint (fix) ./vendor/bin/php-cs-fixer fix + +# Static analysis — NOT part of composer test; must be zero errors +composer phpstan + +# Everything the CI gate runs (tests + lint + static analysis) +composer ci ``` +Every PR is gated by the `CI` workflow on `Tests`, `Lint`, and `Static analysis`. A red check blocks the merge; `develop` never carries a failing test, lint error, or PHPStan error. + ## Key Conventions - **Branch naming** - all branches use `feature/{name}` format, regardless of change type (fix, feature, refactor, docs, etc.). Never use `fix/`, `chore/`, or other prefixes diff --git a/composer.json b/composer.json index 51a4d574..6b558faf 100644 --- a/composer.json +++ b/composer.json @@ -492,6 +492,13 @@ "php-cs-fixer fix --config=.php-cs-fixer.php", "phpcbf --standard=phpcs.xml || true" ], + "phpstan": "php -d memory_limit=2G vendor/bin/phpstan analyse", + "ci": [ + "@test", + "@cs:check", + "php-cs-fixer fix --config=.php-cs-fixer.php --dry-run --diff", + "@phpstan" + ], "rector": "rector process" }, "config": { diff --git a/packages/admin-panel-latte/tests/ResolverIntegrationTest.php b/packages/admin-panel-latte/tests/ResolverIntegrationTest.php index 1afb7d5a..fb87ad48 100644 --- a/packages/admin-panel-latte/tests/ResolverIntegrationTest.php +++ b/packages/admin-panel-latte/tests/ResolverIntegrationTest.php @@ -12,7 +12,7 @@ 'ModuleTemplateResolver resolves admin-panel::dashboard/index to the new admin-panel-latte path', function (): void { $adminPanelLatteDir = dirname(__DIR__); - + $modules = [ new ModuleManifest( name: 'marko/admin-panel-latte', @@ -22,14 +22,14 @@ function (): void { extra: ['marko' => ['templates_for' => 'marko/admin-panel']], ), ]; - + $resolver = new ModuleTemplateResolver( new ModuleRepository($modules), new ViewConfig(new FakeConfigRepository(['view.extension' => '.latte'])), ); - + $result = $resolver->resolve('admin-panel::dashboard/index'); - + expect($result)->toBe($adminPanelLatteDir . '/resources/views/dashboard/index.latte'); - } + }, ); diff --git a/packages/admin-panel-latte/tests/TemplateMigrationTest.php b/packages/admin-panel-latte/tests/TemplateMigrationTest.php index f8f8d715..e4655aa9 100644 --- a/packages/admin-panel-latte/tests/TemplateMigrationTest.php +++ b/packages/admin-panel-latte/tests/TemplateMigrationTest.php @@ -31,7 +31,7 @@ $oldTestPath = dirname(__DIR__, 2) . '/admin-panel/tests/Unit/Template/LayoutTemplateTest.php'; expect(file_exists($oldTestPath))->toBeFalse( - 'LayoutTemplateTest.php should not exist in admin-panel/tests/Unit/Template/' + 'LayoutTemplateTest.php should not exist in admin-panel/tests/Unit/Template/', ); }); diff --git a/packages/admin-panel-twig/tests/TemplateExistenceTest.php b/packages/admin-panel-twig/tests/TemplateExistenceTest.php index 4c841acc..8e8deddf 100644 --- a/packages/admin-panel-twig/tests/TemplateExistenceTest.php +++ b/packages/admin-panel-twig/tests/TemplateExistenceTest.php @@ -9,47 +9,47 @@ 'auth/login.twig exists and renders a form with email and password fields', function () use ($viewsDir): void { $path = $viewsDir . '/auth/login.twig'; - + expect(file_exists($path))->toBeTrue(); - + $contents = file_get_contents($path); - + expect($contents) ->toContain('and($contents)->toContain('name="email"') ->and($contents)->toContain('name="password"'); - } + }, ); test( 'layout/base.twig exists and contains HTML doctype, sidebar include, and content block', function () use ($viewsDir): void { $path = $viewsDir . '/layout/base.twig'; - + expect(file_exists($path))->toBeTrue(); - + $contents = file_get_contents($path); - + expect($contents) ->toContain('') ->and($contents)->toContain("{% include 'admin-panel::partials/sidebar'") ->and($contents)->toContain('{% block content %}'); - } + }, ); test( 'dashboard/index.twig exists and extends layout/base.twig with a content block', function () use ($viewsDir): void { $path = $viewsDir . '/dashboard/index.twig'; - + expect(file_exists($path))->toBeTrue(); - + $contents = file_get_contents($path); - + expect($contents) ->toContain("{% extends 'admin-panel::layout/base' %}") ->and($contents)->toContain('{% block content %}'); - } + }, ); test('partials/sidebar.twig exists and iterates menu items', function () use ($viewsDir): void { @@ -95,16 +95,16 @@ function () use ($viewsDir): void { function () use ($viewsDir): void { $layoutPath = $viewsDir . '/layout/base.twig'; $dashboardPath = $viewsDir . '/dashboard/index.twig'; - + expect(file_exists($layoutPath))->toBeTrue() ->and(file_exists($dashboardPath))->toBeTrue(); - + $layoutContents = file_get_contents($layoutPath); $dashboardContents = file_get_contents($dashboardPath); - + expect($layoutContents)->toContain('{% block content %}') ->and($dashboardContents)->toContain('{% block content %}') ->and($dashboardContents)->toContain('{% endblock %}'); - } + }, ); }); diff --git a/packages/admin-panel/tests/Unit/Controller/DashboardControllerTest.php b/packages/admin-panel/tests/Unit/Controller/DashboardControllerTest.php index 83b7ba1c..68b568e7 100644 --- a/packages/admin-panel/tests/Unit/Controller/DashboardControllerTest.php +++ b/packages/admin-panel/tests/Unit/Controller/DashboardControllerTest.php @@ -107,7 +107,7 @@ public function getMenuItems(): array it('requires authentication via AdminAuthMiddleware for dashboard', function (): void { $middlewareAttributes = (new ReflectionMethod(DashboardController::class, 'index'))->getAttributes( - Middleware::class + Middleware::class, ); expect($middlewareAttributes)->toHaveCount(1) diff --git a/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php b/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php index d24b6ad3..8ba04e1f 100644 --- a/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php +++ b/packages/admin-panel/tests/Unit/EngineSiblingCleanupTest.php @@ -6,7 +6,7 @@ $viewsDir = dirname(__DIR__, 2) . '/resources/views'; expect(is_dir($viewsDir))->toBeFalse( - 'resources/views/ directory should not exist after engine sibling extraction' + 'resources/views/ directory should not exist after engine sibling extraction', ); }); diff --git a/packages/admin/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/admin/tests/Unit/Exceptions/NoDriverExceptionTest.php index 932e77f5..237350cc 100644 --- a/packages/admin/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/admin/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -11,14 +11,14 @@ function (): void { $reflection = new ReflectionClass(NoDriverException::class); $constants = $reflection->getConstants(); - + expect($constants)->toHaveKey('DRIVER_PACKAGES') ->and($constants['DRIVER_PACKAGES'])->toBe([ 'marko/admin-api', 'marko/admin-auth', 'marko/admin-panel', ]); - } + }, ); it('provides suggestion with composer require commands for all driver packages', function (): void { diff --git a/packages/authentication/tests/KnownDriversValidationTest.php b/packages/authentication/tests/KnownDriversValidationTest.php index 9a837a6f..ace23791 100644 --- a/packages/authentication/tests/KnownDriversValidationTest.php +++ b/packages/authentication/tests/KnownDriversValidationTest.php @@ -11,7 +11,7 @@ 'skeleton suggest block contains all authentication drivers', function () use ($knownDriversPath, $skeletonComposerPath) { KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); - } + }, ); test('every authentication driver follows marko slash prefix pattern', function () use ($knownDriversPath) { diff --git a/packages/authentication/tests/Unit/Guard/SessionGuardTest.php b/packages/authentication/tests/Unit/Guard/SessionGuardTest.php index 5870ccd1..271892bb 100644 --- a/packages/authentication/tests/Unit/Guard/SessionGuardTest.php +++ b/packages/authentication/tests/Unit/Guard/SessionGuardTest.php @@ -319,24 +319,21 @@ public function retrieveByCredentials(array $credentials): ?AuthenticatableInter public function validateCredentials( AuthenticatableInterface $user, array $credentials, - ): bool - { + ): bool { return false; } public function retrieveByRememberToken( int|string $identifier, string $token, - ): ?AuthenticatableInterface - { + ): ?AuthenticatableInterface { return $this->userByRememberToken; } public function updateRememberToken( AuthenticatableInterface $user, ?string $token, - ): void - { + ): void { $user->setRememberToken($token); } }; @@ -428,24 +425,21 @@ public function retrieveByCredentials(array $credentials): ?AuthenticatableInter public function validateCredentials( AuthenticatableInterface $user, array $credentials, - ): bool - { + ): bool { return false; } public function retrieveByRememberToken( int|string $identifier, string $token, - ): ?AuthenticatableInterface - { + ): ?AuthenticatableInterface { return $this->userByRememberToken; } public function updateRememberToken( AuthenticatableInterface $user, ?string $token, - ): void - { + ): void { $user->setRememberToken($token); } }; diff --git a/packages/cache/tests/KnownDriversValidationTest.php b/packages/cache/tests/KnownDriversValidationTest.php index dbe97400..e4cdb8da 100644 --- a/packages/cache/tests/KnownDriversValidationTest.php +++ b/packages/cache/tests/KnownDriversValidationTest.php @@ -12,7 +12,7 @@ 'skeleton suggest block contains all cache drivers', function () use ($knownDriversPath, $skeletonComposerPath): void { KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); - } + }, ); test('every cache driver follows marko slash prefix pattern', function () use ($knownDriversPath): void { @@ -23,18 +23,18 @@ function () use ($knownDriversPath, $skeletonComposerPath): void { 'validation test skips skeleton parity assertion when skeleton is absent', function () use ($knownDriversPath): void { $nonExistentPath = __DIR__ . '/non-existent-path/composer.json'; - + expect(file_exists($nonExistentPath))->toBeFalse(); - + $skipped = false; - + try { KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $nonExistentPath); } catch (SkippedWithMessageException $e) { $skipped = true; expect($e->getMessage())->toContain('not found'); } - + expect($skipped)->toBeTrue(); - } + }, ); diff --git a/packages/claude-plugins/tests/Unit/MarketplaceTest.php b/packages/claude-plugins/tests/Unit/MarketplaceTest.php index db5374c7..c2d414b6 100644 --- a/packages/claude-plugins/tests/Unit/MarketplaceTest.php +++ b/packages/claude-plugins/tests/Unit/MarketplaceTest.php @@ -59,16 +59,16 @@ 'marketplace.json lives at the monorepo repo root at .claude-plugin/marketplace.json (NOT inside packages/claude-plugins/)', function (): void { $insidePackage = dirname(__DIR__, 2) . '/.claude-plugin/marketplace.json'; - + expect(file_exists($this->marketplacePath))->toBeTrue() ->and(file_exists($insidePackage))->toBeFalse(); - } + }, ); it('marketplace.json references the official schema URL via $schema', function (): void { expect($this->marketplace)->toHaveKey('$schema') ->and($this->marketplace['$schema'])->toBe( - 'https://json.schemastore.org/claude-code-plugin-marketplace.json' + 'https://json.schemastore.org/claude-code-plugin-marketplace.json', ); }); @@ -91,7 +91,7 @@ function (): void { ->and($plugin['author'])->toHaveKey('name') ->and($plugin)->toHaveKey('category'); } - } + }, ); it( @@ -100,35 +100,35 @@ function (): void { $plugins = $this->marketplace['plugins']; $names = array_column($plugins, 'name'); $sources = array_column($plugins, 'source'); - + expect(count($plugins))->toBe(3) ->and($names)->toContain('marko-skills') ->and($names)->toContain('marko-lsp') ->and($names)->toContain('marko-mcp'); - + // Sources must be explicit relative paths from marketplace root. - // Anthropic's marketplace schema rejects bare-name sources with metadata.pluginRoot. - foreach ($sources as $source) { + // Anthropic's marketplace schema rejects bare-name sources with metadata.pluginRoot. + foreach ($sources as $source) { expect($source)->toStartWith('./packages/claude-plugins/plugins/'); } - } + }, ); it( 'marketplace.json plugin sources point at the actual plugin directories under packages/claude-plugins/plugins/', function (): void { $plugins = $this->marketplace['plugins']; - + $expected = [ 'marko-skills' => './packages/claude-plugins/plugins/marko-skills', 'marko-lsp' => './packages/claude-plugins/plugins/marko-lsp', 'marko-mcp' => './packages/claude-plugins/plugins/marko-mcp', ]; - + foreach ($plugins as $plugin) { expect($plugin['source'])->toBe($expected[$plugin['name']]); } - } + }, ); it( @@ -138,21 +138,21 @@ function (): void { ->and($this->marketplace['owner'])->toHaveKey('name') ->and($this->marketplace['owner']['name'])->toBeString() ->and(strlen($this->marketplace['owner']['name']))->toBeGreaterThan(0); - } + }, ); it( 'marketplace.json conforms to the schema captured in Task 001\'s schemas/marketplace.json artifact', function (): void { // Required fields per Task 001 finding F7: name (string), owner (object with name), plugins (array) - expect($this->marketplace)->toBeArray() - ->and($this->marketplace)->toHaveKey('name') - ->and($this->marketplace['name'])->toBeString() - ->and($this->marketplace)->toHaveKey('owner') - ->and($this->marketplace['owner'])->toBeArray() - ->and($this->marketplace['owner'])->toHaveKey('name') - ->and($this->marketplace)->toHaveKey('plugins') - ->and($this->marketplace['plugins'])->toBeArray(); - } + expect($this->marketplace)->toBeArray() + ->and($this->marketplace)->toHaveKey('name') + ->and($this->marketplace['name'])->toBeString() + ->and($this->marketplace)->toHaveKey('owner') + ->and($this->marketplace['owner'])->toBeArray() + ->and($this->marketplace['owner'])->toHaveKey('name') + ->and($this->marketplace)->toHaveKey('plugins') + ->and($this->marketplace['plugins'])->toBeArray(); + }, ); }); diff --git a/packages/claude-plugins/tests/Unit/MarkoLspPluginTest.php b/packages/claude-plugins/tests/Unit/MarkoLspPluginTest.php index aeb4caf4..827f5b9b 100644 --- a/packages/claude-plugins/tests/Unit/MarkoLspPluginTest.php +++ b/packages/claude-plugins/tests/Unit/MarkoLspPluginTest.php @@ -15,11 +15,11 @@ 'plugin.json declares name "marko-lsp" and a description matching the marketplace.json entry', function (): void { $manifest = json_decode(file_get_contents($this->pluginJsonPath), true); - + expect(file_exists($this->pluginJsonPath))->toBeTrue() ->and($manifest['name'])->toBe('marko-lsp') ->and($manifest['description'])->toBe('Marko LSP bundle (PHP intelephense) for Claude Code.'); - } + }, ); it('plugin.json includes author with name "Marko Framework"', function (): void { @@ -39,11 +39,11 @@ function (): void { '.lsp.json top-level structure is an object keyed by server name (not wrapped in lspServers per Task 001 F1 verbatim)', function (): void { $lsp = json_decode(file_get_contents($this->lspJsonPath), true); - + expect(file_exists($this->lspJsonPath))->toBeTrue() ->and(array_key_exists('marko-lsp', $lsp))->toBeTrue() ->and(array_key_exists('lspServers', $lsp))->toBeFalse(); - } + }, ); it('.lsp.json marko-lsp.command is "${CLAUDE_PLUGIN_ROOT}/bin/marko-lsp"', function (): void { @@ -64,13 +64,13 @@ function (): void { function (): void { $lsp = json_decode(file_get_contents($this->lspJsonPath), true); $ext = $lsp['marko-lsp']['extensionToLanguage']; - + expect($ext)->toBeArray() ->and($ext)->toHaveCount(1) ->and(array_key_exists('.php', $ext))->toBeTrue() ->and($ext['.php'])->toBe('php') ->and(array_key_exists('.latte', $ext))->toBeFalse(); - } + }, ); it('.lsp.json each extensionToLanguage key starts with a leading dot', function (): void { @@ -115,12 +115,12 @@ function (): void { 'README.md explains marko-lsp coexists with php-lsp (intelephense), what marko-lsp adds beyond it, the recommendation to uninstall php-lsp@claude-plugins-official to avoid duplication, and how to verify with claude plugin list', function (): void { $contents = file_exists($this->readmePath) ? file_get_contents($this->readmePath) : ''; - + expect(file_exists($this->readmePath))->toBeTrue() ->and(str_contains($contents, 'intelephense'))->toBeTrue() ->and(str_contains($contents, 'php-lsp@claude-plugins-official'))->toBeTrue() ->and(str_contains($contents, 'uninstall'))->toBeTrue() ->and(str_contains($contents, 'claude plugin list'))->toBeTrue(); - } + }, ); }); diff --git a/packages/claude-plugins/tests/Unit/MarkoSkillsPluginTest.php b/packages/claude-plugins/tests/Unit/MarkoSkillsPluginTest.php index 00de50a0..82d2edac 100644 --- a/packages/claude-plugins/tests/Unit/MarkoSkillsPluginTest.php +++ b/packages/claude-plugins/tests/Unit/MarkoSkillsPluginTest.php @@ -19,12 +19,12 @@ 'plugin.json description states the plugin provides scaffolding skills for Marko modules and plugins', function (): void { $manifest = json_decode(file_get_contents($this->pluginJsonPath), true); - + expect($manifest)->toHaveKey('description') ->and($manifest['description'])->toBe( - 'Marko-specific skills (create-module, create-plugin) for Claude Code.' + 'Marko-specific skills (create-module, create-plugin) for Claude Code.', ); - } + }, ); it('plugin.json includes author with name "Marko Framework"', function (): void { @@ -51,13 +51,13 @@ function (): void { function (): void { $readmePath = $this->pluginRoot . '/README.md'; $content = file_exists($readmePath) ? file_get_contents($readmePath) : ''; - + expect(file_exists($readmePath))->toBeTrue() ->and($content)->toContain('create-module') ->and($content)->toContain('create-plugin') ->and($content)->toContain('/marko-skills:create-module') ->and($content)->toContain('/marko-skills:create-plugin') ->and($content)->toContain('/plugin install marko-skills@marko'); - } + }, ); }); diff --git a/packages/claude-plugins/tests/Unit/ReadmeTest.php b/packages/claude-plugins/tests/Unit/ReadmeTest.php index cdb18378..9798241e 100644 --- a/packages/claude-plugins/tests/Unit/ReadmeTest.php +++ b/packages/claude-plugins/tests/Unit/ReadmeTest.php @@ -28,7 +28,7 @@ function (): void { ->and($this->contents)->toContain('marko-skills') ->and($this->contents)->toContain('marko-lsp') ->and($this->contents)->toContain('marko-mcp'); - } + }, ); it('README.md includes the install command via marko devai:install', function (): void { @@ -45,12 +45,12 @@ function (): void { 'README.md follows the slim-pointer convention from docs/DOCS-STANDARDS.md (no exhaustive feature lists, no inline code samples beyond a quick install example)', function (): void { expect($this->contents)->not->toBeNull(); - + // Slim-pointer: file should be short (under 80 lines) - $lines = substr_count($this->contents, "\n") + 1; + $lines = substr_count($this->contents, "\n") + 1; expect($lines)->toBeLessThan(80) // Must have a Documentation section ->and($this->contents)->toContain('## Documentation'); - } + }, ); }); diff --git a/packages/claude-plugins/tests/Unit/Skills/CreateModuleSkillTest.php b/packages/claude-plugins/tests/Unit/Skills/CreateModuleSkillTest.php index 6838841b..3935b255 100644 --- a/packages/claude-plugins/tests/Unit/Skills/CreateModuleSkillTest.php +++ b/packages/claude-plugins/tests/Unit/Skills/CreateModuleSkillTest.php @@ -13,12 +13,12 @@ 'SKILL.md frontmatter has name "create-module" and a non-empty description with concrete trigger examples', function (): void { $content = file_exists($this->skillMd) ? file_get_contents($this->skillMd) : ''; - + // Parse YAML frontmatter between the two --- delimiters - preg_match('/^---\n(.*?)\n---/s', $content, $matches); - + preg_match('/^---\n(.*?)\n---/s', $content, $matches); + $frontmatter = $matches[1] ?? ''; - + expect(file_exists($this->skillMd))->toBeTrue() ->and($matches)->not->toBeEmpty() ->and($frontmatter)->toContain('name: create-module') @@ -26,7 +26,7 @@ function (): void { ->and($frontmatter)->toContain('description:') // Must contain concrete trigger examples (in the body — YAML block scalar or inline) ->and($content)->toContain('create a module named'); - } + }, ); it('SKILL.md is under 500 lines', function (): void { @@ -53,9 +53,9 @@ function (): void { 'SKILL.md instructs the agent to copy templates from assets/ rather than inlining file content', function (): void { $content = file_get_contents($this->skillMd); - + expect($content)->toContain('assets/'); - } + }, ); it( @@ -63,15 +63,15 @@ function (): void { function (): void { $tmplPath = $this->assetsDir . '/composer.json.tmpl'; $content = file_exists($tmplPath) ? file_get_contents($tmplPath) : ''; - + // Substitute placeholders with literal strings and validate JSON - $substituted = str_replace( + $substituted = str_replace( ['{{vendor}}', '{{name}}', '{{Vendor}}', '{{Name}}'], ['acme', 'payment', 'Acme', 'Payment'], $content, ); $decoded = json_decode($substituted, true); - + expect(file_exists($tmplPath))->toBeTrue() ->and($content)->toContain('{{vendor}}') ->and($content)->toContain('{{name}}') @@ -82,7 +82,7 @@ function (): void { ->and($decoded)->toHaveKey('autoload') ->and($decoded)->toHaveKey('extra') ->and($decoded['extra']['marko']['module'])->toBeTrue(); - } + }, ); it('composer.json.monorepo.tmpl uses self.version constraints for marko/* requirements', function (): void { @@ -142,8 +142,8 @@ function (): void { 'the original SKILL.md at packages/devai/resources/ai/skills/marko-create-module/ is deleted', function (): void { $originalPath = dirname(__DIR__, 4) . '/devai/resources/ai/skills/marko-create-module/SKILL.md'; - + expect(file_exists($originalPath))->toBeFalse(); - } + }, ); }); diff --git a/packages/codeindexer/src/Attributes/AttributeParser.php b/packages/codeindexer/src/Attributes/AttributeParser.php index cc790447..68ea658a 100644 --- a/packages/codeindexer/src/Attributes/AttributeParser.php +++ b/packages/codeindexer/src/Attributes/AttributeParser.php @@ -342,8 +342,7 @@ private function findClasses(array $ast): array private function walkNodes( array $nodes, callable $callback, - ): void - { + ): void { foreach ($nodes as $node) { if (!$node instanceof Node) { continue; @@ -372,8 +371,7 @@ private function resolveClassName(Class_ $class): string private function classIsInNamespace( Class_ $class, ModuleInfo $module, - ): bool - { + ): bool { $className = $this->resolveClassName($class); $ns = rtrim($module->namespace, '\\') . '\\'; @@ -409,8 +407,7 @@ private function methodAttributes(Node\Stmt\ClassMethod $method): array private function attrNameMatches( Attribute $attr, string $fqn, - ): bool - { + ): bool { $name = $attr->name; if ($name instanceof Node\Name\FullyQualified || $name instanceof Node\Name) { return ltrim($name->toString(), '\\') === ltrim($fqn, '\\'); @@ -426,8 +423,7 @@ private function getArgValueAsString( array $args, string $namedKey, int $positionalIndex, - ): ?string - { + ): ?string { $value = $this->getArgValue($args, $namedKey, $positionalIndex); if ($value === null) { return null; @@ -454,8 +450,7 @@ private function getArgValueAsInt( array $args, string $namedKey, int $positionalIndex, - ): ?int - { + ): ?int { $value = $this->getArgValue($args, $namedKey, $positionalIndex); if ($value === null) { return null; @@ -475,8 +470,7 @@ private function getArgValue( array $args, string $namedKey, int $positionalIndex, - ): ?Node\Expr - { + ): ?Node\Expr { // Try named arg first foreach ($args as $arg) { if ($arg->name !== null && $arg->name->toString() === $namedKey) { diff --git a/packages/codeindexer/src/Config/ConfigScanner.php b/packages/codeindexer/src/Config/ConfigScanner.php index 9f39707c..e115cb62 100644 --- a/packages/codeindexer/src/Config/ConfigScanner.php +++ b/packages/codeindexer/src/Config/ConfigScanner.php @@ -56,8 +56,7 @@ public function diagnostics(): array private function scanFile( string $file, string $moduleName, - ): array - { + ): array { $code = file_get_contents($file); if ($code === false) { return []; @@ -105,8 +104,7 @@ private function flattenArray( string $prefix, string $file, string $moduleName, - ): array - { + ): array { $entries = []; foreach ($array->items as $item) { diff --git a/packages/codeindexer/src/Module/ModuleWalker.php b/packages/codeindexer/src/Module/ModuleWalker.php index 5662f4a8..1d1dd644 100644 --- a/packages/codeindexer/src/Module/ModuleWalker.php +++ b/packages/codeindexer/src/Module/ModuleWalker.php @@ -4,6 +4,7 @@ namespace Marko\CodeIndexer\Module; +use Closure; use FilesystemIterator; use Marko\CodeIndexer\ValueObject\ModuleInfo; use Marko\Core\Path\ProjectPaths; @@ -163,7 +164,7 @@ private function loadManifest(string $path): array private static function stripClosures(array $value): array { foreach ($value as $k => $v) { - if ($v instanceof \Closure) { + if ($v instanceof Closure) { $value[$k] = ''; } elseif (is_array($v)) { $value[$k] = self::stripClosures($v); diff --git a/packages/codeindexer/src/Translations/TranslationScanner.php b/packages/codeindexer/src/Translations/TranslationScanner.php index afa03796..8687952f 100644 --- a/packages/codeindexer/src/Translations/TranslationScanner.php +++ b/packages/codeindexer/src/Translations/TranslationScanner.php @@ -49,8 +49,7 @@ private function scanFile( string $group, string $locale, string $moduleName, - ): array - { + ): array { $code = file_get_contents($file); if ($code === false) { return []; @@ -89,8 +88,7 @@ private function flattenArray( string $locale, string $moduleName, string $file, - ): array - { + ): array { $entries = []; foreach ($array->items as $item) { @@ -113,7 +111,7 @@ private function flattenArray( $group, $locale, $moduleName, - $file + $file, )]; } else { $entries[] = new TranslationEntry( diff --git a/packages/codeindexer/tests/Unit/Attributes/AttributeParserTest.php b/packages/codeindexer/tests/Unit/Attributes/AttributeParserTest.php index f3dd31bd..f6c11a70 100644 --- a/packages/codeindexer/tests/Unit/Attributes/AttributeParserTest.php +++ b/packages/codeindexer/tests/Unit/Attributes/AttributeParserTest.php @@ -70,9 +70,9 @@ class Broken { public function oops(: void {} } 'does not require target classes to be autoloadable (pure AST traversal, no class loading)', function () use ($fixtureBase): void { // Create a temporary module pointing to a file that references non-autoloadable classes - $tmpDir = sys_get_temp_dir() . '/marko-ast-test-' . uniqid(); + $tmpDir = sys_get_temp_dir() . '/marko-ast-test-' . uniqid(); mkdir($tmpDir . '/src', 0777, true); - + file_put_contents($tmpDir . '/src/NonAutoloadableObserver.php', <<<'PHP' $parser->observers($module))->not->toThrow(Throwable::class); - } + // but crucially, no exception is thrown even though the classes don't exist. + expect(fn () => $parser->observers($module))->not->toThrow(Throwable::class); + }, ); it( 'resolves short attribute names via file use statements to fully qualified class names', function () use ($fixtureBase, $fixtureModule): void { // The fixture files use short names like `Observer`, `Plugin`, etc. via `use` statements. - // The NameResolver must expand them to FQNs for matching to work. - $parser = new AttributeParser(); - + // The NameResolver must expand them to FQNs for matching to work. + $parser = new AttributeParser(); + // If NameResolver was NOT used, none of these would return results - // because the attribute names would remain unresolved short names. - expect($parser->observers($fixtureModule))->not->toBeEmpty() - ->and($parser->plugins($fixtureModule))->not->toBeEmpty() - ->and($parser->preferences($fixtureModule))->not->toBeEmpty() - ->and($parser->commands($fixtureModule))->not->toBeEmpty() - ->and($parser->routes($fixtureModule))->not->toBeEmpty(); - } + // because the attribute names would remain unresolved short names. + expect($parser->observers($fixtureModule))->not->toBeEmpty() + ->and($parser->plugins($fixtureModule))->not->toBeEmpty() + ->and($parser->preferences($fixtureModule))->not->toBeEmpty() + ->and($parser->commands($fixtureModule))->not->toBeEmpty() + ->and($parser->routes($fixtureModule))->not->toBeEmpty(); + }, ); it('returns empty entries for modules with no attributes', function (): void { @@ -163,16 +163,16 @@ function () use ($fixtureBase, $fixtureModule): void { function () use ($fixtureModule): void { $parser = new AttributeParser(); $routes = $parser->routes($fixtureModule); - + $active = array_values(array_filter($routes, fn ($e) => $e->method !== 'DISABLED')); - + expect($active)->toHaveCount(6); - + $byAction = []; foreach ($active as $entry) { $byAction[$entry->action] = $entry; } - + expect($byAction['index']->method)->toBe('GET') ->and($byAction['index']->path)->toBe('/posts') ->and($byAction['show']->method)->toBe('GET') @@ -181,7 +181,7 @@ function () use ($fixtureModule): void { ->and($byAction['update']->method)->toBe('PUT') ->and($byAction['patch']->method)->toBe('PATCH') ->and($byAction['destroy']->method)->toBe('DELETE'); - } + }, ); it('parses Command attributes from class-level declarations', function () use ($fixtureModule): void { @@ -201,14 +201,14 @@ function () use ($fixtureModule): void { function () use ($fixtureModule): void { $parser = new AttributeParser(); $preferences = $parser->preferences($fixtureModule); - + expect($preferences)->toHaveCount(1); - + $entry = $preferences[0]; expect($entry->implementation)->toEndWith('CustomLoggerPreference') ->and($entry->interface)->toBe('Fixture\AttributeFixtures\Contracts\LoggerInterface') ->and($entry->module)->toBe('fixture/attributefixtures'); - } + }, ); it('parses Plugin attributes and associates methods to target class', function () use ($fixtureModule): void { diff --git a/packages/codeindexer/tests/Unit/Cache/IndexCacheTest.php b/packages/codeindexer/tests/Unit/Cache/IndexCacheTest.php index 53c27738..3f196656 100644 --- a/packages/codeindexer/tests/Unit/Cache/IndexCacheTest.php +++ b/packages/codeindexer/tests/Unit/Cache/IndexCacheTest.php @@ -1184,370 +1184,410 @@ public function scan(ModuleInfo $m): array // ── staleness-recheck-on-read tests ────────────────────────────────────────── -it('it reflects a newly added app module on the next read after the data was already loaded', function () use (&$tmpDirs): void { - $rootPath = makeTempDir(); - $tmpDirs[] = $rootPath; - - // Initial app module on disk - $appDir = $rootPath . '/app'; - $initialModulePath = $appDir . '/initial-module'; - mkdir($initialModulePath . '/src', 0755, true); - file_put_contents($initialModulePath . '/src/Initial.php', ' $modules */ - public function __construct(private array &$modules) {} + $initialModule = new ModuleInfo('test/initial', $initialModulePath, 'Test\\Initial\\'); - public function walk(): array + // Spy walker: starts returning 1 module, will return 2 after we update $modules + $modules = [$initialModule]; + $walker = new class ($modules) extends ModuleWalker { - return $this->modules; - } - }; + /** @param list $modules */ + public function __construct(private array &$modules) {} - $attributeParser = new class () extends AttributeParser - { - public function observers(ModuleInfo $m): array - { - return []; - } + public function walk(): array + { + return $this->modules; + } + }; - public function plugins(ModuleInfo $m): array + $attributeParser = new class () extends AttributeParser { - return []; - } + public function observers(ModuleInfo $m): array + { + return []; + } - public function preferences(ModuleInfo $m): array - { - return []; - } + public function plugins(ModuleInfo $m): array + { + return []; + } - public function commands(ModuleInfo $m): array - { - return []; - } + public function preferences(ModuleInfo $m): array + { + return []; + } - public function routes(ModuleInfo $m): array - { - return []; - } - }; - $configScanner = new class () extends ConfigScanner - { - public function scan(ModuleInfo $m): array + public function commands(ModuleInfo $m): array + { + return []; + } + + public function routes(ModuleInfo $m): array + { + return []; + } + }; + $configScanner = new class () extends ConfigScanner { - return []; - } - }; - $templateScanner = new class () extends TemplateScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $templateScanner = new class () extends TemplateScanner { - return []; - } - }; - $translationScanner = new class () extends TranslationScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $translationScanner = new class () extends TranslationScanner { - return []; - } - }; - - $cache = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); + public function scan(ModuleInfo $m): array + { + return []; + } + }; - // First read populates $this->data (singleton scenario) - $first = $cache->getModules(); - expect($first)->toHaveCount(1); + $cache = makeIndexCache( + $rootPath, + $walker, + $attributeParser, + $configScanner, + $templateScanner, + $translationScanner, + ); - // Simulate a new app module appearing on disk AND being tracked by the walker - $newModulePath = $appDir . '/new-module'; - mkdir($newModulePath . '/src', 0755, true); - file_put_contents($newModulePath . '/src/New.php', 'data (singleton scenario) + $first = $cache->getModules(); + expect($first)->toHaveCount(1); - // Same instance: second read must detect the addition and rebuild - $second = $cache->getModules(); - expect($second)->toHaveCount(2); -}); + // Simulate a new app module appearing on disk AND being tracked by the walker + $newModulePath = $appDir . '/new-module'; + mkdir($newModulePath . '/src', 0755, true); + file_put_contents($newModulePath . '/src/New.php', 'getModules(); + expect($second)->toHaveCount(2); + }, +); - $appDir = $rootPath . '/app'; - $modulePath = $appDir . '/route-module'; - mkdir($modulePath . '/src', 0755, true); - file_put_contents($modulePath . '/src/Ctrl.php', ' $modules */ - public function __construct(private array &$modules) {} + $module = new ModuleInfo('test/route-module', $modulePath, 'Test\\Route\\'); + $modules = [$module]; - public function walk(): array + // Route list controlled externally + $routes = []; + $walker = new class ($modules) extends ModuleWalker { - return $this->modules; - } - }; - $attributeParser = new class ($routes) extends AttributeParser - { - /** @param list $routes */ - public function __construct(private array &$routes) {} + /** @param list $modules */ + public function __construct(private array &$modules) {} - public function observers(ModuleInfo $m): array + public function walk(): array + { + return $this->modules; + } + }; + $attributeParser = new class ($routes) extends AttributeParser { - return []; - } + /** @param list $routes */ + public function __construct(private array &$routes) {} - public function plugins(ModuleInfo $m): array - { - return []; - } + public function observers(ModuleInfo $m): array + { + return []; + } - public function preferences(ModuleInfo $m): array - { - return []; - } + public function plugins(ModuleInfo $m): array + { + return []; + } - public function commands(ModuleInfo $m): array - { - return []; - } + public function preferences(ModuleInfo $m): array + { + return []; + } - public function routes(ModuleInfo $m): array - { - return $this->routes; - } - }; - $configScanner = new class () extends ConfigScanner - { - public function scan(ModuleInfo $m): array + public function commands(ModuleInfo $m): array + { + return []; + } + + public function routes(ModuleInfo $m): array + { + return $this->routes; + } + }; + $configScanner = new class () extends ConfigScanner { - return []; - } - }; - $templateScanner = new class () extends TemplateScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $templateScanner = new class () extends TemplateScanner { - return []; - } - }; - $translationScanner = new class () extends TranslationScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $translationScanner = new class () extends TranslationScanner { - return []; - } - }; - - $cache = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); - - // First read: no routes - $first = $cache->getRoutes(); - expect($first)->toBeEmpty(); + public function scan(ModuleInfo $m): array + { + return []; + } + }; - // Simulate a new file change on disk - file_put_contents($modulePath . '/src/NewRoute.php', 'getRoutes(); - expect($second)->toHaveCount(1); -}); + // First read: no routes + $first = $cache->getRoutes(); + expect($first)->toBeEmpty(); -it('it reflects an edit to a tracked app or modules source file on the next read after first load', function () use (&$tmpDirs): void { - $rootPath = makeTempDir(); - $tmpDirs[] = $rootPath; + // Simulate a new file change on disk + file_put_contents($modulePath . '/src/NewRoute.php', 'getRoutes(); + expect($second)->toHaveCount(1); + }, +); - $module = new ModuleInfo('test/edit-module', $modulePath, 'Test\\Edit\\'); - $modules = [$module]; - $routeList = []; +it( + 'it reflects an edit to a tracked app or modules source file on the next read after first load', + function () use (&$tmpDirs): void { + $rootPath = makeTempDir(); + $tmpDirs[] = $rootPath; - $walker = new class ($modules) extends ModuleWalker - { - /** @param list $modules */ - public function __construct(private array &$modules) {} + $appDir = $rootPath . '/app'; + $modulePath = $appDir . '/edit-module'; + mkdir($modulePath . '/src', 0755, true); + $srcFile = $modulePath . '/src/Service.php'; + file_put_contents($srcFile, 'modules; - } - }; - $attributeParser = new class ($routeList) extends AttributeParser - { - /** @param list $routes */ - public function __construct(private array &$routes) {} + $module = new ModuleInfo('test/edit-module', $modulePath, 'Test\\Edit\\'); + $modules = [$module]; + $routeList = []; - public function observers(ModuleInfo $m): array + $walker = new class ($modules) extends ModuleWalker { - return []; - } + /** @param list $modules */ + public function __construct(private array &$modules) {} - public function plugins(ModuleInfo $m): array + public function walk(): array + { + return $this->modules; + } + }; + $attributeParser = new class ($routeList) extends AttributeParser { - return []; - } + /** @param list $routes */ + public function __construct(private array &$routes) {} - public function preferences(ModuleInfo $m): array - { - return []; - } + public function observers(ModuleInfo $m): array + { + return []; + } - public function commands(ModuleInfo $m): array - { - return []; - } + public function plugins(ModuleInfo $m): array + { + return []; + } - public function routes(ModuleInfo $m): array - { - return $this->routes; - } - }; - $configScanner = new class () extends ConfigScanner - { - public function scan(ModuleInfo $m): array + public function preferences(ModuleInfo $m): array + { + return []; + } + + public function commands(ModuleInfo $m): array + { + return []; + } + + public function routes(ModuleInfo $m): array + { + return $this->routes; + } + }; + $configScanner = new class () extends ConfigScanner { - return []; - } - }; - $templateScanner = new class () extends TemplateScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $templateScanner = new class () extends TemplateScanner { - return []; - } - }; - $translationScanner = new class () extends TranslationScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $translationScanner = new class () extends TranslationScanner { - return []; - } - }; - - $cache = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); + public function scan(ModuleInfo $m): array + { + return []; + } + }; - // First read - $first = $cache->getRoutes(); - expect($first)->toBeEmpty(); + $cache = makeIndexCache( + $rootPath, + $walker, + $attributeParser, + $configScanner, + $templateScanner, + $translationScanner, + ); - // Simulate a file edit with a newer mtime - $cacheFile = $rootPath . '/.marko/index.cache'; - touch($srcFile, filemtime($cacheFile) + 10); - $routeList[] = makeIndexRoute(); + // First read + $first = $cache->getRoutes(); + expect($first)->toBeEmpty(); - // Same instance: second read detects the edit and rebuilds - $second = $cache->getRoutes(); - expect($second)->toHaveCount(1); -}); + // Simulate a file edit with a newer mtime + $cacheFile = $rootPath . '/.marko/index.cache'; + touch($srcFile, filemtime($cacheFile) + 10); + $routeList[] = makeIndexRoute(); -it('it reflects a deleted app module on the next read by comparing the tracked path set', function () use (&$tmpDirs): void { - $rootPath = makeTempDir(); - $tmpDirs[] = $rootPath; + // Same instance: second read detects the edit and rebuilds + $second = $cache->getRoutes(); + expect($second)->toHaveCount(1); + }, +); - $appDir = $rootPath . '/app'; - $modulePath = $appDir . '/delete-module'; - mkdir($modulePath . '/src', 0755, true); - $srcFile = $modulePath . '/src/ToDelete.php'; - file_put_contents($srcFile, ' $modules */ - public function __construct(private array &$modules) {} + $module = new ModuleInfo('test/delete-module', $modulePath, 'Test\\Delete\\'); + $modules = [$module]; - public function walk(): array - { - return $this->modules; - } - }; - $attributeParser = new class () extends AttributeParser - { - public function observers(ModuleInfo $m): array + $walker = new class ($modules) extends ModuleWalker { - return []; - } + /** @param list $modules */ + public function __construct(private array &$modules) {} - public function plugins(ModuleInfo $m): array + public function walk(): array + { + return $this->modules; + } + }; + $attributeParser = new class () extends AttributeParser { - return []; - } + public function observers(ModuleInfo $m): array + { + return []; + } - public function preferences(ModuleInfo $m): array - { - return []; - } + public function plugins(ModuleInfo $m): array + { + return []; + } - public function commands(ModuleInfo $m): array - { - return []; - } + public function preferences(ModuleInfo $m): array + { + return []; + } - public function routes(ModuleInfo $m): array - { - return []; - } - }; - $configScanner = new class () extends ConfigScanner - { - public function scan(ModuleInfo $m): array + public function commands(ModuleInfo $m): array + { + return []; + } + + public function routes(ModuleInfo $m): array + { + return []; + } + }; + $configScanner = new class () extends ConfigScanner { - return []; - } - }; - $templateScanner = new class () extends TemplateScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $templateScanner = new class () extends TemplateScanner { - return []; - } - }; - $translationScanner = new class () extends TranslationScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $translationScanner = new class () extends TranslationScanner { - return []; - } - }; + public function scan(ModuleInfo $m): array + { + return []; + } + }; - $cache = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); + $cache = makeIndexCache( + $rootPath, + $walker, + $attributeParser, + $configScanner, + $templateScanner, + $translationScanner, + ); - // First read: 1 module with 1 src file - $first = $cache->getModules(); - expect($first)->toHaveCount(1); + // First read: 1 module with 1 src file + $first = $cache->getModules(); + expect($first)->toHaveCount(1); - // Delete the source file on disk and remove module from walker - unlink($srcFile); - rmdir($modulePath . '/src'); - rmdir($modulePath); - $modules = []; + // Delete the source file on disk and remove module from walker + unlink($srcFile); + rmdir($modulePath . '/src'); + rmdir($modulePath); + $modules = []; - // Same instance: second read detects the deletion via path-set comparison - $second = $cache->getModules(); - expect($second)->toBeEmpty(); -}); + // Same instance: second read detects the deletion via path-set comparison + $second = $cache->getModules(); + expect($second)->toBeEmpty(); + }, +); it('it does not rebuild when no tracked file under app or modules changed', function () use (&$tmpDirs): void { $rootPath = makeTempDir(); @@ -1624,7 +1664,14 @@ public function scan(ModuleInfo $m): array } }; - $cache = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); + $cache = makeIndexCache( + $rootPath, + $walker, + $attributeParser, + $configScanner, + $templateScanner, + $translationScanner, + ); // First read triggers build (observers() called once per module) $cache->getModules(); @@ -1639,477 +1686,534 @@ public function scan(ModuleInfo $m): array ->and($scanCountAfterThree)->toBe($scanCountAfterFirst); }); -it('it does not trigger a rebuild when only a vendor file changes after first load', function () use (&$tmpDirs): void { - $rootPath = makeTempDir(); - $tmpDirs[] = $rootPath; - - // Create a vendor module on disk (path under vendor/) - $vendorModulePath = $rootPath . '/vendor/vendor-org/vendor-pkg'; - mkdir($vendorModulePath . '/src', 0755, true); - $vendorSrcFile = $vendorModulePath . '/src/VendorClass.php'; - file_put_contents($vendorSrcFile, 'vendorModule, $this->appModule]; - } - }; - // Count observers() calls — these only fire during build(), not during isStale() - $attributeParser = new class ($scanCount) extends AttributeParser - { - public function __construct(private int &$scanCount) {} + public function __construct( + private readonly ModuleInfo $vendorModule, + private readonly ModuleInfo $appModule, + ) {} - public function observers(ModuleInfo $m): array + public function walk(): array + { + return [$this->vendorModule, $this->appModule]; + } + }; + // Count observers() calls — these only fire during build(), not during isStale() + $attributeParser = new class ($scanCount) extends AttributeParser { - $this->scanCount++; + public function __construct(private int &$scanCount) {} - return []; - } + public function observers(ModuleInfo $m): array + { + $this->scanCount++; - public function plugins(ModuleInfo $m): array - { - return []; - } + return []; + } - public function preferences(ModuleInfo $m): array - { - return []; - } + public function plugins(ModuleInfo $m): array + { + return []; + } - public function commands(ModuleInfo $m): array - { - return []; - } + public function preferences(ModuleInfo $m): array + { + return []; + } - public function routes(ModuleInfo $m): array - { - return []; - } - }; - $configScanner = new class () extends ConfigScanner - { - public function scan(ModuleInfo $m): array + public function commands(ModuleInfo $m): array + { + return []; + } + + public function routes(ModuleInfo $m): array + { + return []; + } + }; + $configScanner = new class () extends ConfigScanner { - return []; - } - }; - $templateScanner = new class () extends TemplateScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $templateScanner = new class () extends TemplateScanner { - return []; - } - }; - $translationScanner = new class () extends TranslationScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $translationScanner = new class () extends TranslationScanner { - return []; - } - }; - - $cache = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); + public function scan(ModuleInfo $m): array + { + return []; + } + }; - // First read — builds (observers called for each module: vendor + app = 2 calls) - $cache->getModules(); - $scanCountAfterFirst = $scanCount; + $cache = makeIndexCache( + $rootPath, + $walker, + $attributeParser, + $configScanner, + $templateScanner, + $translationScanner, + ); - // Touch only a vendor file (newer than cache) - $cacheFile = $rootPath . '/.marko/index.cache'; - touch($vendorSrcFile, filemtime($cacheFile) + 10); + // First read — builds (observers called for each module: vendor + app = 2 calls) + $cache->getModules(); + $scanCountAfterFirst = $scanCount; - // Second read: vendor change must NOT trigger rebuild — scan count must not increase - $cache->getModules(); - $scanCountAfterSecond = $scanCount; + // Touch only a vendor file (newer than cache) + $cacheFile = $rootPath . '/.marko/index.cache'; + touch($vendorSrcFile, filemtime($cacheFile) + 10); - expect($scanCountAfterSecond)->toBe($scanCountAfterFirst); -}); + // Second read: vendor change must NOT trigger rebuild — scan count must not increase + $cache->getModules(); + $scanCountAfterSecond = $scanCount; -it('it rebuilds at most once for several successive reads following a single change', function () use (&$tmpDirs): void { - $rootPath = makeTempDir(); - $tmpDirs[] = $rootPath; + expect($scanCountAfterSecond)->toBe($scanCountAfterFirst); + }, +); - $appDir = $rootPath . '/app'; - $modulePath = $appDir . '/once-module'; - mkdir($modulePath . '/src', 0755, true); - $srcFile = $modulePath . '/src/Once.php'; - file_put_contents($srcFile, 'module]; - } - }; - // Count observers() calls — only happen during build() - $attributeParser = new class ($scanCount) extends AttributeParser - { - public function __construct(private int &$scanCount) {} + public function __construct(private readonly ModuleInfo $module) {} - public function observers(ModuleInfo $m): array + public function walk(): array + { + return [$this->module]; + } + }; + // Count observers() calls — only happen during build() + $attributeParser = new class ($scanCount) extends AttributeParser { - $this->scanCount++; + public function __construct(private int &$scanCount) {} - return []; - } + public function observers(ModuleInfo $m): array + { + $this->scanCount++; - public function plugins(ModuleInfo $m): array - { - return []; - } + return []; + } - public function preferences(ModuleInfo $m): array - { - return []; - } + public function plugins(ModuleInfo $m): array + { + return []; + } - public function commands(ModuleInfo $m): array - { - return []; - } + public function preferences(ModuleInfo $m): array + { + return []; + } - public function routes(ModuleInfo $m): array - { - return []; - } - }; - $configScanner = new class () extends ConfigScanner - { - public function scan(ModuleInfo $m): array + public function commands(ModuleInfo $m): array + { + return []; + } + + public function routes(ModuleInfo $m): array + { + return []; + } + }; + $configScanner = new class () extends ConfigScanner { - return []; - } - }; - $templateScanner = new class () extends TemplateScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $templateScanner = new class () extends TemplateScanner { - return []; - } - }; - $translationScanner = new class () extends TranslationScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $translationScanner = new class () extends TranslationScanner { - return []; - } - }; - - $cache = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); + public function scan(ModuleInfo $m): array + { + return []; + } + }; - // First read - $cache->getModules(); - $scanCountAfterFirst = $scanCount; + $cache = makeIndexCache( + $rootPath, + $walker, + $attributeParser, + $configScanner, + $templateScanner, + $translationScanner, + ); - // Backdate the cache file so the existing src file appears newer than the cache. - // After the rebuild, the new cache is written at "now" which is > the src file - // mtime, so subsequent reads are not stale (no rebuild storm). - $cacheFile = $rootPath . '/.marko/index.cache'; - touch($cacheFile, filemtime($srcFile) - 20); + // First read + $cache->getModules(); + $scanCountAfterFirst = $scanCount; - // Three successive reads after one change — only ONE rebuild should fire - $cache->getModules(); - $cache->getModules(); - $cache->getModules(); - $scanCountAfterThree = $scanCount; + // Backdate the cache file so the existing src file appears newer than the cache. + // After the rebuild, the new cache is written at "now" which is > the src file + // mtime, so subsequent reads are not stale (no rebuild storm). + $cacheFile = $rootPath . '/.marko/index.cache'; + touch($cacheFile, filemtime($srcFile) - 20); - // Only one additional build should have fired (scan count increases by 1 per build call) - expect($scanCountAfterThree - $scanCountAfterFirst)->toBe(1); -}); + // Three successive reads after one change — only ONE rebuild should fire + $cache->getModules(); + $cache->getModules(); + $cache->getModules(); + $scanCountAfterThree = $scanCount; -it('it treats a cache payload missing the tracked path set as stale so old caches rebuild once', function () use (&$tmpDirs): void { - $rootPath = makeTempDir(); - $tmpDirs[] = $rootPath; + // Only one additional build should have fired (scan count increases by 1 per build call) + expect($scanCountAfterThree - $scanCountAfterFirst)->toBe(1); + }, +); - $appDir = $rootPath . '/app'; - $modulePath = $appDir . '/legacy-module'; - mkdir($modulePath . '/src', 0755, true); - file_put_contents($modulePath . '/src/Legacy.php', ' $modules */ - public function __construct(private array &$modules) {} + $module = new ModuleInfo('test/legacy', $modulePath, 'Test\\Legacy\\'); + $modules = [$module]; - public function walk(): array - { - return $this->modules; - } - }; - $attributeParser = new class () extends AttributeParser - { - public function observers(ModuleInfo $m): array + $walker = new class ($modules) extends ModuleWalker { - return []; - } + /** @param list $modules */ + public function __construct(private array &$modules) {} - public function plugins(ModuleInfo $m): array + public function walk(): array + { + return $this->modules; + } + }; + $attributeParser = new class () extends AttributeParser { - return []; - } + public function observers(ModuleInfo $m): array + { + return []; + } - public function preferences(ModuleInfo $m): array - { - return []; - } + public function plugins(ModuleInfo $m): array + { + return []; + } - public function commands(ModuleInfo $m): array - { - return []; - } + public function preferences(ModuleInfo $m): array + { + return []; + } - public function routes(ModuleInfo $m): array + public function commands(ModuleInfo $m): array + { + return []; + } + + public function routes(ModuleInfo $m): array + { + return []; + } + }; + $configScanner = new class () extends ConfigScanner { - return []; - } - }; - $configScanner = new class () extends ConfigScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $templateScanner = new class () extends TemplateScanner { - return []; - } - }; - $templateScanner = new class () extends TemplateScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $translationScanner = new class () extends TranslationScanner { - return []; - } - }; - $translationScanner = new class () extends TranslationScanner - { - public function scan(ModuleInfo $m): array - { - return []; - } - }; + public function scan(ModuleInfo $m): array + { + return []; + } + }; - // Write a legacy cache payload without 'trackedPaths' key - mkdir($rootPath . '/.marko', 0755, true); - $legacyPayload = [ - 'modules' => [$module], - 'observers' => [], - 'plugins' => [], - 'preferences' => [], - 'commands' => [], - 'routes' => [], - 'configKeys' => [], - 'templates' => [], - 'translationKeys' => [], - // intentionally no 'trackedPaths' key - ]; - file_put_contents($rootPath . '/.marko/index.cache', serialize($legacyPayload)); - - $cache = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); - - // Must not throw, must load (or rebuild) cleanly - $result = $cache->getModules(); - expect($result)->toHaveCount(1); -}); + // Write a legacy cache payload without 'trackedPaths' key + mkdir($rootPath . '/.marko', 0755, true); + $legacyPayload = [ + 'modules' => [$module], + 'observers' => [], + 'plugins' => [], + 'preferences' => [], + 'commands' => [], + 'routes' => [], + 'configKeys' => [], + 'templates' => [], + 'translationKeys' => [], + // intentionally no 'trackedPaths' key + ]; + file_put_contents($rootPath . '/.marko/index.cache', serialize($legacyPayload)); -it('it persists the tracked path set as a plain array or string that requires no new unserialize allowed class', function () use (&$tmpDirs): void { - $rootPath = makeTempDir(); - $tmpDirs[] = $rootPath; + $cache = makeIndexCache( + $rootPath, + $walker, + $attributeParser, + $configScanner, + $templateScanner, + $translationScanner, + ); - $appDir = $rootPath . '/app'; - $modulePath = $appDir . '/tracked-module'; - mkdir($modulePath . '/src', 0755, true); - file_put_contents($modulePath . '/src/Tracked.php', 'getModules(); + expect($result)->toHaveCount(1); + }, +); - $module = new ModuleInfo('test/tracked', $modulePath, 'Test\\Tracked\\'); +it( + 'it persists the tracked path set as a plain array or string that requires no new unserialize allowed class', + function () use (&$tmpDirs): void { + $rootPath = makeTempDir(); + $tmpDirs[] = $rootPath; - $walker = new class ($module) extends ModuleWalker - { - public function __construct(private readonly ModuleInfo $module) {} + $appDir = $rootPath . '/app'; + $modulePath = $appDir . '/tracked-module'; + mkdir($modulePath . '/src', 0755, true); + file_put_contents($modulePath . '/src/Tracked.php', 'module]; - } - }; - $attributeParser = new class () extends AttributeParser - { - public function observers(ModuleInfo $m): array - { - return []; - } + $module = new ModuleInfo('test/tracked', $modulePath, 'Test\\Tracked\\'); - public function plugins(ModuleInfo $m): array + $walker = new class ($module) extends ModuleWalker { - return []; - } + public function __construct(private readonly ModuleInfo $module) {} - public function preferences(ModuleInfo $m): array + public function walk(): array + { + return [$this->module]; + } + }; + $attributeParser = new class () extends AttributeParser { - return []; - } + public function observers(ModuleInfo $m): array + { + return []; + } - public function commands(ModuleInfo $m): array - { - return []; - } + public function plugins(ModuleInfo $m): array + { + return []; + } - public function routes(ModuleInfo $m): array - { - return []; - } - }; - $configScanner = new class () extends ConfigScanner - { - public function scan(ModuleInfo $m): array + public function preferences(ModuleInfo $m): array + { + return []; + } + + public function commands(ModuleInfo $m): array + { + return []; + } + + public function routes(ModuleInfo $m): array + { + return []; + } + }; + $configScanner = new class () extends ConfigScanner { - return []; - } - }; - $templateScanner = new class () extends TemplateScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $templateScanner = new class () extends TemplateScanner { - return []; - } - }; - $translationScanner = new class () extends TranslationScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $translationScanner = new class () extends TranslationScanner { - return []; - } - }; - - $cache = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); - $cache->build(); - - $cacheFile = $rootPath . '/.marko/index.cache'; - $raw = file_get_contents($cacheFile); - - // Deserialize with the existing allowlist (no new classes) - $data = unserialize($raw, [ - 'allowed_classes' => [ - CommandEntry::class, - ConfigKeyEntry::class, - ModuleInfo::class, - ObserverEntry::class, - PluginEntry::class, - PreferenceEntry::class, - RouteEntry::class, - TemplateEntry::class, - TranslationEntry::class, - ], - ]); - - expect($data)->toBeArray() - ->and($data)->toHaveKey('trackedPaths') - ->and($data['trackedPaths'])->toBeArray(); + public function scan(ModuleInfo $m): array + { + return []; + } + }; - // Each entry in trackedPaths must be a plain string - foreach ($data['trackedPaths'] as $path) { - expect($path)->toBeString(); - } -}); + $cache = makeIndexCache( + $rootPath, + $walker, + $attributeParser, + $configScanner, + $templateScanner, + $translationScanner, + ); + $cache->build(); -it('it preserves first-load semantics by loading a fresh on-disk cache without rescanning', function () use (&$tmpDirs): void { - $rootPath = makeTempDir(); - $tmpDirs[] = $rootPath; + $cacheFile = $rootPath . '/.marko/index.cache'; + $raw = file_get_contents($cacheFile); + + // Deserialize with the existing allowlist (no new classes) + $data = unserialize($raw, [ + 'allowed_classes' => [ + CommandEntry::class, + ConfigKeyEntry::class, + ModuleInfo::class, + ObserverEntry::class, + PluginEntry::class, + PreferenceEntry::class, + RouteEntry::class, + TemplateEntry::class, + TranslationEntry::class, + ], + ]); + + expect($data)->toBeArray() + ->and($data)->toHaveKey('trackedPaths') + ->and($data['trackedPaths'])->toBeArray(); + + // Each entry in trackedPaths must be a plain string + foreach ($data['trackedPaths'] as $path) { + expect($path)->toBeString(); + } + }, +); - $scanCount = 0; +it( + 'it preserves first-load semantics by loading a fresh on-disk cache without rescanning', + function () use (&$tmpDirs): void { + $rootPath = makeTempDir(); + $tmpDirs[] = $rootPath; - $walker = new class () extends ModuleWalker - { - public function __construct() {} + $scanCount = 0; - public function walk(): array + $walker = new class () extends ModuleWalker { - return []; - } - }; - $attributeParser = new class ($scanCount) extends AttributeParser - { - public function __construct(private int &$count) {} + public function __construct() {} - public function observers(ModuleInfo $m): array + public function walk(): array + { + return []; + } + }; + $attributeParser = new class ($scanCount) extends AttributeParser { - $this->count++; + public function __construct(private int &$count) {} - return []; - } + public function observers(ModuleInfo $m): array + { + $this->count++; - public function plugins(ModuleInfo $m): array - { - return []; - } + return []; + } - public function preferences(ModuleInfo $m): array - { - return []; - } + public function plugins(ModuleInfo $m): array + { + return []; + } - public function commands(ModuleInfo $m): array - { - return []; - } + public function preferences(ModuleInfo $m): array + { + return []; + } - public function routes(ModuleInfo $m): array - { - return []; - } - }; - $configScanner = new class () extends ConfigScanner - { - public function scan(ModuleInfo $m): array + public function commands(ModuleInfo $m): array + { + return []; + } + + public function routes(ModuleInfo $m): array + { + return []; + } + }; + $configScanner = new class () extends ConfigScanner { - return []; - } - }; - $templateScanner = new class () extends TemplateScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $templateScanner = new class () extends TemplateScanner { - return []; - } - }; - $translationScanner = new class () extends TranslationScanner - { - public function scan(ModuleInfo $m): array + public function scan(ModuleInfo $m): array + { + return []; + } + }; + $translationScanner = new class () extends TranslationScanner { - return []; - } - }; + public function scan(ModuleInfo $m): array + { + return []; + } + }; - // Build and save cache using first instance - $cache = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); - $cache->build(); + // Build and save cache using first instance + $cache = makeIndexCache( + $rootPath, + $walker, + $attributeParser, + $configScanner, + $templateScanner, + $translationScanner, + ); + $cache->build(); - // Load on a fresh instance — should not re-invoke scanners - $cache2 = makeIndexCache($rootPath, $walker, $attributeParser, $configScanner, $templateScanner, $translationScanner); - $loaded = $cache2->load(); + // Load on a fresh instance — should not re-invoke scanners + $cache2 = makeIndexCache( + $rootPath, + $walker, + $attributeParser, + $configScanner, + $templateScanner, + $translationScanner, + ); + $loaded = $cache2->load(); - expect($loaded)->toBeTrue() - ->and($scanCount)->toBe(0); -}); + expect($loaded)->toBeTrue() + ->and($scanCount)->toBe(0); + }, +); diff --git a/packages/codeindexer/tests/Unit/Config/ConfigScannerTest.php b/packages/codeindexer/tests/Unit/Config/ConfigScannerTest.php index 43ece86c..04c8d007 100644 --- a/packages/codeindexer/tests/Unit/Config/ConfigScannerTest.php +++ b/packages/codeindexer/tests/Unit/Config/ConfigScannerTest.php @@ -23,27 +23,27 @@ function () use ($fixtures): void { $scanner = new ConfigScanner(); $module = new ModuleInfo('module-c', $fixtures . '/module-c', 'Marko\\ModuleC'); - + $entries = $scanner->scan($module); $diagnostics = $scanner->diagnostics(); - + $keys = array_map(fn (ConfigKeyEntry $e): string => $e->key, $entries); $byKey = []; foreach ($entries as $entry) { $byKey[$entry->key] = $entry; } - + // dynamic.php returns a function call — no keys, but diagnostic recorded - $diagnosticFiles = array_map(fn (array $d): string => basename($d['file']), $diagnostics); + $diagnosticFiles = array_map(fn (array $d): string => basename($d['file']), $diagnostics); expect($diagnosticFiles)->toContain('dynamic.php'); - + // partial.php has a mix — known keys indexed, dynamic value recorded as type='dynamic' - expect($keys)->toContain('partial.driver') - ->and($keys)->toContain('partial.port') - ->and($keys)->toContain('partial.host') - ->and($byKey['partial.port']->type)->toBe('dynamic') - ->and($byKey['partial.port']->defaultValue)->toBeNull(); - } + expect($keys)->toContain('partial.driver') + ->and($keys)->toContain('partial.port') + ->and($keys)->toContain('partial.host') + ->and($byKey['partial.port']->type)->toBe('dynamic') + ->and($byKey['partial.port']->defaultValue)->toBeNull(); + }, ); it('does not include or eval config files — reads values via AST only', function (): void { diff --git a/packages/codeindexer/tests/Unit/SkeletonTest.php b/packages/codeindexer/tests/Unit/SkeletonTest.php index f94125af..fe0907b1 100644 --- a/packages/codeindexer/tests/Unit/SkeletonTest.php +++ b/packages/codeindexer/tests/Unit/SkeletonTest.php @@ -79,7 +79,7 @@ 'defines the IndexCacheInterface contract (the one swappable seam — cache backend)', function (): void { expect(interface_exists(IndexCacheInterface::class))->toBeTrue(); - } + }, ); it( @@ -96,58 +96,58 @@ function (): void { TemplateEntry::class, TranslationEntry::class, ]; - + foreach ($valueObjects as $class) { $reflection = new ReflectionClass($class); expect($reflection->isReadOnly())->toBeTrue("Expected $class to be readonly"); } - + $moduleInfo = new ModuleInfo('test', '/path', 'Ns'); expect($moduleInfo->name)->toBe('test') ->and($moduleInfo->path)->toBe('/path') ->and($moduleInfo->namespace)->toBe('Ns'); - + $observerEntry = new ObserverEntry('Cls', 'evt', 'mth', 10); expect($observerEntry->class)->toBe('Cls') ->and($observerEntry->event)->toBe('evt') ->and($observerEntry->method)->toBe('mth') ->and($observerEntry->sortOrder)->toBe(10); - + $pluginEntry = new PluginEntry('Cls', 'tgt', 'mth', 'before', 5); expect($pluginEntry->class)->toBe('Cls') ->and($pluginEntry->target)->toBe('tgt') ->and($pluginEntry->method)->toBe('mth') ->and($pluginEntry->type)->toBe('before') ->and($pluginEntry->sortOrder)->toBe(5); - + $preferenceEntry = new PreferenceEntry('Iface', 'Impl', 'mod'); expect($preferenceEntry->interface)->toBe('Iface') ->and($preferenceEntry->implementation)->toBe('Impl') ->and($preferenceEntry->module)->toBe('mod'); - + $commandEntry = new CommandEntry('cmd:name', 'CmdCls', 'desc'); expect($commandEntry->name)->toBe('cmd:name') ->and($commandEntry->class)->toBe('CmdCls') ->and($commandEntry->description)->toBe('desc'); - + $routeEntry = new RouteEntry('GET', '/path', 'Ctrl', 'index'); expect($routeEntry->method)->toBe('GET') ->and($routeEntry->path)->toBe('/path') ->and($routeEntry->class)->toBe('Ctrl') ->and($routeEntry->action)->toBe('index'); - + $configKeyEntry = new ConfigKeyEntry('app.name', 'string', 'Marko', 'core'); expect($configKeyEntry->key)->toBe('app.name') ->and($configKeyEntry->type)->toBe('string') ->and($configKeyEntry->defaultValue)->toBe('Marko') ->and($configKeyEntry->module)->toBe('core'); - + $templateEntry = new TemplateEntry('mod', 'tmpl-1', '/tmpl.latte', 'latte'); expect($templateEntry->moduleName)->toBe('mod') ->and($templateEntry->templateName)->toBe('tmpl-1') ->and($templateEntry->absolutePath)->toBe('/tmpl.latte') ->and($templateEntry->extension)->toBe('latte'); - + $translationEntry = new TranslationEntry( key: 'messages.hello', group: 'messages', @@ -162,5 +162,5 @@ function (): void { ->and($translationEntry->locale)->toBe('en') ->and($translationEntry->namespace)->toBe('mod') ->and($translationEntry->module)->toBe('mod'); - } + }, ); diff --git a/packages/core/src/Attributes/Command.php b/packages/core/src/Attributes/Command.php index f53dc04a..556b96f3 100644 --- a/packages/core/src/Attributes/Command.php +++ b/packages/core/src/Attributes/Command.php @@ -9,6 +9,9 @@ #[Attribute(Attribute::TARGET_CLASS)] readonly class Command { + /** + * @param list $aliases + */ public function __construct( public string $name, public string $description = '', diff --git a/packages/core/src/Command/CommandDefinition.php b/packages/core/src/Command/CommandDefinition.php index c7c4c371..8df7ef6d 100644 --- a/packages/core/src/Command/CommandDefinition.php +++ b/packages/core/src/Command/CommandDefinition.php @@ -9,6 +9,9 @@ */ readonly class CommandDefinition { + /** + * @param list $aliases + */ public function __construct( public string $commandClass, public string $name, diff --git a/packages/core/src/Command/Input.php b/packages/core/src/Command/Input.php index ebbcfbed..6949f138 100644 --- a/packages/core/src/Command/Input.php +++ b/packages/core/src/Command/Input.php @@ -27,11 +27,11 @@ public function getCommand(): ?string /** * Returns arguments after the command name (index 0 = script, index 1 = command). * - * @return array + * @return list */ public function getArguments(): array { - return array_values(array_slice($this->arguments, 2)); + return array_slice($this->arguments, 2); } /** diff --git a/packages/core/src/Commands/ListCommand.php b/packages/core/src/Commands/ListCommand.php index 0289b5b1..c1c04c41 100644 --- a/packages/core/src/Commands/ListCommand.php +++ b/packages/core/src/Commands/ListCommand.php @@ -36,7 +36,7 @@ public function execute( if ($definition->aliases !== []) { $displayNames[$definition->name] = $definition->name . ' (' . implode( ', ', - $definition->aliases + $definition->aliases, ) . ')'; } else { $displayNames[$definition->name] = $definition->name; diff --git a/packages/core/src/Module/ModuleAutoloader.php b/packages/core/src/Module/ModuleAutoloader.php index b6993eaa..62749ecd 100644 --- a/packages/core/src/Module/ModuleAutoloader.php +++ b/packages/core/src/Module/ModuleAutoloader.php @@ -58,8 +58,10 @@ public function register(): void } } - private function registerPsr4(string $namespace, string $basePath): void - { + private function registerPsr4( + string $namespace, + string $basePath, + ): void { spl_autoload_register(function (string $class) use ($namespace, $basePath): void { if (!str_starts_with($class, $namespace)) { return; diff --git a/packages/core/src/Module/ModuleManifest.php b/packages/core/src/Module/ModuleManifest.php index 0b90c7bc..cf3d0c94 100644 --- a/packages/core/src/Module/ModuleManifest.php +++ b/packages/core/src/Module/ModuleManifest.php @@ -22,7 +22,9 @@ * @param array $after Modules to load before this one (from module.php) * @param array $before Modules to load after this one (from module.php) * @param array $bindings Interface to implementation bindings (from module.php) - * @param array $singletons Shared interface to implementation bindings (from module.php) + * @param array $singletons Shared interface to implementation bindings (from module.php). + * String keys bind interface => implementation; int keys are list-style entries naming a class to + * autowire as a singleton, which BindingRegistry::registerModule() distinguishes via is_int(). * @param string $path Absolute path to module directory * @param string $source Discovery source: vendor, modules, or app * @param array $autoload PSR-4 autoload configuration from composer.json (namespace => path) diff --git a/packages/core/src/Plugin/InterceptorClassGenerator.php b/packages/core/src/Plugin/InterceptorClassGenerator.php index 1adcd2d6..e6f68d8e 100644 --- a/packages/core/src/Plugin/InterceptorClassGenerator.php +++ b/packages/core/src/Plugin/InterceptorClassGenerator.php @@ -181,6 +181,8 @@ private function extractClassName(string $code): string /** * Collect all methods from a reflected interface, including inherited ones. * + * @param ReflectionClass $reflection + * * @return array */ private function collectInterfaceMethods(ReflectionClass $reflection): array diff --git a/packages/core/src/Plugin/PluginInterceptedInterface.php b/packages/core/src/Plugin/PluginInterceptedInterface.php index 82328c72..f2ce236d 100644 --- a/packages/core/src/Plugin/PluginInterceptedInterface.php +++ b/packages/core/src/Plugin/PluginInterceptedInterface.php @@ -4,8 +4,26 @@ namespace Marko\Core\Plugin; +use Marko\Core\Container\ContainerInterface; + interface PluginInterceptedInterface { + /** + * Initialize the interception state. Interceptor classes are constructed without + * arguments (interface wrappers) or without a constructor at all (concrete + * subclasses), so PluginInterceptor calls this immediately after construction. + * + * Implemented by the PluginInterception trait, which every interceptor uses. + * + * @param class-string $pluginTargetClass + */ + public function initInterception( + object $pluginTarget, + string $pluginTargetClass, + ContainerInterface $pluginContainer, + PluginRegistry $pluginRegistry, + ): void; + /** * Get the underlying target instance that this interceptor wraps. */ diff --git a/packages/core/src/Plugin/PluginInterceptor.php b/packages/core/src/Plugin/PluginInterceptor.php index e907f505..aad7a197 100644 --- a/packages/core/src/Plugin/PluginInterceptor.php +++ b/packages/core/src/Plugin/PluginInterceptor.php @@ -59,7 +59,6 @@ public function createProxy( $className = $this->generator->generateInterfaceWrapper( $originalId, $this->registry, - $this->container, ); /** @var PluginInterceptedInterface&object $instance */ @@ -77,7 +76,6 @@ public function createProxy( $className = $this->generator->generateInterfaceWrapper( $effectiveTarget, $this->registry, - $this->container, ); /** @var PluginInterceptedInterface&object $instance */ @@ -91,7 +89,6 @@ public function createProxy( $className = $this->generator->generateConcreteSubclass( $resolvedId, $this->registry, - $this->container, ); /** @var PluginInterceptedInterface&object $instance */ diff --git a/packages/core/tests/Feature/Plugin/PluginInterceptionIntegrationTest.php b/packages/core/tests/Feature/Plugin/PluginInterceptionIntegrationTest.php index 118fe6d4..4233dfd9 100644 --- a/packages/core/tests/Feature/Plugin/PluginInterceptionIntegrationTest.php +++ b/packages/core/tests/Feature/Plugin/PluginInterceptionIntegrationTest.php @@ -49,8 +49,7 @@ class PIIT_HasherAfterPlugin public function hash( mixed $result, string $value, - ): string - { + ): string { self::$log[] = "after:$result"; return strtoupper((string) $result); @@ -73,8 +72,7 @@ public function hash(string $value): null public function hashAfter( mixed $result, string $value, - ): string - { + ): string { self::$log[] = "after:$result"; return (string) $result; @@ -107,8 +105,7 @@ class PIIT_FirstAfterPlugin public function hash( mixed $result, string $value, - ): string - { + ): string { self::$log[] = "first-after:$result"; return "[$result]"; @@ -123,8 +120,7 @@ class PIIT_SecondAfterPlugin public function hash( mixed $result, string $value, - ): string - { + ): string { self::$log[] = "second-after:$result"; return "$result!"; diff --git a/packages/core/tests/Unit/ApplicationTest.php b/packages/core/tests/Unit/ApplicationTest.php index f567c2c3..f6e0a5e3 100644 --- a/packages/core/tests/Unit/ApplicationTest.php +++ b/packages/core/tests/Unit/ApplicationTest.php @@ -1696,7 +1696,7 @@ public function execute( expect(fn () => $app->handleRequest()) ->toThrow( RuntimeException::class, - 'Cannot handle HTTP requests: marko/routing is not installed. Run: composer require marko/routing' + 'Cannot handle HTTP requests: marko/routing is not installed. Run: composer require marko/routing', ); }); diff --git a/packages/core/tests/Unit/Module/DependencyResolverTest.php b/packages/core/tests/Unit/Module/DependencyResolverTest.php index f09909b5..3dc7377b 100644 --- a/packages/core/tests/Unit/Module/DependencyResolverTest.php +++ b/packages/core/tests/Unit/Module/DependencyResolverTest.php @@ -345,54 +345,60 @@ ->not->toContain('not installed'); }); -it('throws a missing dependency error (not an empty-chain circular error) for a before-after soft-ordering deadlock', function (): void { - // Module A declares both after: [B] and before: [B] — an unsatisfiable ordering deadlock - $moduleA = new ModuleManifest( - name: 'vendor/module-a', - version: '1.0.0', - after: ['vendor/module-b'], - before: ['vendor/module-b'], - ); - $moduleB = new ModuleManifest( - name: 'vendor/module-b', - version: '1.0.0', - ); - - $resolver = new DependencyResolver(); - - expect(fn () => $resolver->resolve([$moduleA, $moduleB])) - ->toThrow(MissingDependencyException::class); -}); - -it('names the unsorted modules and their unmet ordering constraints in the missing-dependency message', function (): void { - // Module A declares both after: [B] and before: [B] — unsatisfiable - $moduleA = new ModuleManifest( - name: 'vendor/module-a', - version: '1.0.0', - after: ['vendor/module-b'], - before: ['vendor/module-b'], - ); - $moduleB = new ModuleManifest( - name: 'vendor/module-b', - version: '1.0.0', - ); - - $resolver = new DependencyResolver(); - - $exception = null; - - try { - $resolver->resolve([$moduleA, $moduleB]); - } catch (MissingDependencyException $e) { - $exception = $e; - } - - expect($exception)->not->toBeNull() - ->and($exception->getMessage()) - ->toContain('vendor/module-a') - ->toContain('after:vendor/module-b') - ->toContain('before:vendor/module-b'); -}); +it( + 'throws a missing dependency error (not an empty-chain circular error) for a before-after soft-ordering deadlock', + function (): void { + // Module A declares both after: [B] and before: [B] — an unsatisfiable ordering deadlock + $moduleA = new ModuleManifest( + name: 'vendor/module-a', + version: '1.0.0', + after: ['vendor/module-b'], + before: ['vendor/module-b'], + ); + $moduleB = new ModuleManifest( + name: 'vendor/module-b', + version: '1.0.0', + ); + + $resolver = new DependencyResolver(); + + expect(fn () => $resolver->resolve([$moduleA, $moduleB])) + ->toThrow(MissingDependencyException::class); + }, +); + +it( + 'names the unsorted modules and their unmet ordering constraints in the missing-dependency message', + function (): void { + // Module A declares both after: [B] and before: [B] — unsatisfiable + $moduleA = new ModuleManifest( + name: 'vendor/module-a', + version: '1.0.0', + after: ['vendor/module-b'], + before: ['vendor/module-b'], + ); + $moduleB = new ModuleManifest( + name: 'vendor/module-b', + version: '1.0.0', + ); + + $resolver = new DependencyResolver(); + + $exception = null; + + try { + $resolver->resolve([$moduleA, $moduleB]); + } catch (MissingDependencyException $e) { + $exception = $e; + } + + expect($exception)->not->toBeNull() + ->and($exception->getMessage()) + ->toContain('vendor/module-a') + ->toContain('after:vendor/module-b') + ->toContain('before:vendor/module-b'); + }, +); it('throws a circular dependency error with a populated chain for a real two-module require cycle', function (): void { // A requires B, B requires A — a real cycle via require @@ -479,19 +485,22 @@ expect($names)->toBe(['vendor/module-a', 'vendor/module-b']); }); -it('still resolves successfully when an enabled module requires a non-marko composer package (absent from the module list)', function (): void { - $moduleA = new ModuleManifest( - name: 'vendor/module-a', - version: '1.0.0', - require: ['psr/container' => '^2.0', 'symfony/http-foundation' => '^6.0'], - ); - - $resolver = new DependencyResolver(); - $sorted = $resolver->resolve([$moduleA]); - - expect($sorted)->toHaveCount(1) - ->and($sorted[0]->name)->toBe('vendor/module-a'); -}); +it( + 'still resolves successfully when an enabled module requires a non-marko composer package (absent from the module list)', + function (): void { + $moduleA = new ModuleManifest( + name: 'vendor/module-a', + version: '1.0.0', + require: ['psr/container' => '^2.0', 'symfony/http-foundation' => '^6.0'], + ); + + $resolver = new DependencyResolver(); + $sorted = $resolver->resolve([$moduleA]); + + expect($sorted)->toHaveCount(1) + ->and($sorted[0]->name)->toBe('vendor/module-a'); + }, +); it('throws a missing dependency error naming a module that requires a disabled dependency', function (): void { $moduleA = new ModuleManifest( diff --git a/packages/core/tests/Unit/Plugin/PluginInterceptedInterfaceTest.php b/packages/core/tests/Unit/Plugin/PluginInterceptedInterfaceTest.php new file mode 100644 index 00000000..2af78f64 --- /dev/null +++ b/packages/core/tests/Unit/Plugin/PluginInterceptedInterfaceTest.php @@ -0,0 +1,46 @@ +hasMethod('initInterception'))->toBeTrue( + 'PluginInterceptor calls initInterception() through this interface, so the interface must declare it', + ); +}); + +it('declares initInterception with the signature the trait implements', function (): void { + $interface = (new ReflectionClass(PluginInterceptedInterface::class))->getMethod('initInterception'); + $trait = (new ReflectionClass(PluginInterception::class))->getMethod('initInterception'); + + $typeName = function (ReflectionParameter $p): string { + $type = $p->getType(); + + return $type instanceof ReflectionNamedType ? $type->getName() : (string) $type; + }; + + expect(array_map($typeName, $interface->getParameters())) + ->toBe(array_map($typeName, $trait->getParameters())) + ->and(array_map($typeName, $interface->getParameters())) + ->toBe(['object', 'string', ContainerInterface::class, PluginRegistry::class]); +}); + +it('is satisfied by the trait every interceptor uses', function (): void { + $interceptor = new class () implements PluginInterceptedInterface + { + use PluginInterception; + }; + + expect($interceptor)->toBeInstanceOf(PluginInterceptedInterface::class); +}); diff --git a/packages/core/tests/Unit/Plugin/PluginInterceptorTest.php b/packages/core/tests/Unit/Plugin/PluginInterceptorTest.php index df749604..117db97e 100644 --- a/packages/core/tests/Unit/Plugin/PluginInterceptorTest.php +++ b/packages/core/tests/Unit/Plugin/PluginInterceptorTest.php @@ -53,8 +53,7 @@ class PIT_GreeterAfterPlugin public function greet( mixed $result, string $name, - ): mixed - { + ): mixed { self::$callLog[] = 'PIT_GreeterAfterPlugin::greet'; return strtoupper((string) $result); @@ -136,8 +135,7 @@ class PIT_ArgsService public function process( string $name, int $count, - ): string - { + ): string { self::$callLog[] = "PIT_ArgsService::process($name, $count)"; return "processed: $name, $count"; @@ -151,8 +149,7 @@ class PIT_ArgsBeforePlugin public function process( string $name, int $count, - ): ?string - { + ): ?string { self::$receivedArgs = ['name' => $name, 'count' => $count]; PIT_ArgsService::$callLog[] = "PIT_ArgsBeforePlugin::process($name, $count)"; @@ -165,8 +162,7 @@ class PIT_ArgsModifyingBeforePlugin public function process( string $name, int $count, - ): ?array - { + ): ?array { return ['modified-name', $count + 10]; } } @@ -179,8 +175,7 @@ public function process( mixed $result, string $name, int $count, - ): mixed - { + ): mixed { self::$receivedArgs = ['result' => $result, 'name' => $name, 'count' => $count]; return $result; @@ -264,8 +259,7 @@ class PIT_RequiredParamService public function create( string $name, int $age, - ): string - { + ): string { return "$name is $age"; } } @@ -275,8 +269,7 @@ class PIT_WrongCountBeforePlugin public function create( string $name, int $age, - ): ?array - { + ): ?array { return ['only-one']; } } @@ -290,8 +283,7 @@ class PIT_ChainArgService public function transform( string $text, int $mult, - ): string - { + ): string { return "result: $text x$mult"; } } @@ -301,8 +293,7 @@ class PIT_ChainArgFirstPlugin public function transform( string $text, int $mult, - ): ?array - { + ): ?array { return ["$text-first", $mult + 1]; } } @@ -312,8 +303,7 @@ class PIT_ChainArgSecondPlugin public function transform( string $text, int $mult, - ): ?array - { + ): ?array { return ["$text-second", $mult + 1]; } } @@ -349,8 +339,7 @@ class PIT_CompleteFlowAfterPlugin public function process( mixed $result, string $input, - ): string - { + ): string { PIT_CompleteFlowService::$callLog[] = "PIT_CompleteFlowAfterPlugin::process($result, $input)"; return "$result [modified]"; @@ -587,7 +576,7 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy = $interceptor->createProxy( PIT_GreeterInterface::class, PIT_ConcreteGreeter::class, - new PIT_ConcreteGreeter() + new PIT_ConcreteGreeter(), ); expect($proxy)->toBeInstanceOf(PIT_GreeterInterface::class); @@ -653,7 +642,7 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy = $interceptor->createProxy( PIT_ShortCircuitService::class, PIT_ShortCircuitService::class, - new PIT_ShortCircuitService() + new PIT_ShortCircuitService(), ); $result = $proxy->fetch('mykey'); @@ -676,7 +665,7 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy = $interceptor->createProxy( PIT_ShortCircuitService::class, PIT_ShortCircuitService::class, - new PIT_ShortCircuitService() + new PIT_ShortCircuitService(), ); $result = $proxy->fetch('mykey'); @@ -717,7 +706,7 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy = $interceptor->createProxy( PIT_RequiredParamService::class, PIT_RequiredParamService::class, - new PIT_RequiredParamService() + new PIT_RequiredParamService(), ); expect(fn () => $proxy->create('Alice', 30))->toThrow(PluginArgumentCountException::class); @@ -743,7 +732,7 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy = $interceptor->createProxy( PIT_ChainArgService::class, PIT_ChainArgService::class, - new PIT_ChainArgService() + new PIT_ChainArgService(), ); $result = $proxy->transform('hello', 1); @@ -793,7 +782,7 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy->process('test', 42); expect(PIT_ArgsAfterPlugin::$receivedArgs)->toBe( - ['result' => 'processed: test, 42', 'name' => 'test', 'count' => 42] + ['result' => 'processed: test, 42', 'name' => 'test', 'count' => 42], ); }); @@ -817,7 +806,7 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy = $interceptor->createProxy( PIT_ResultModService::class, PIT_ResultModService::class, - new PIT_ResultModService() + new PIT_ResultModService(), ); $result = $proxy->getValue(); @@ -875,7 +864,7 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy = $interceptor->createProxy( PIT_CompleteFlowService::class, PIT_CompleteFlowService::class, - new PIT_CompleteFlowService() + new PIT_CompleteFlowService(), ); $result = $proxy->process('test'); @@ -946,7 +935,7 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy = $interceptor->createProxy( PIT_MethodNameService::class, PIT_MethodNameService::class, - new PIT_MethodNameService() + new PIT_MethodNameService(), ); $result = $proxy->save('hello'); @@ -970,7 +959,7 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): $proxy = $interceptor->createProxy( PIT_MethodNameService::class, PIT_MethodNameService::class, - new PIT_MethodNameService() + new PIT_MethodNameService(), ); $result = $proxy->save('test data'); @@ -984,26 +973,26 @@ function makePluginInterceptor(Container $container, PluginRegistry $registry): function (): void { $container = new Container(); $registry = new PluginRegistry(); - + $registry->register(new PluginDefinition( pluginClass: PIT_HasherPlugin::class, targetClass: PIT_HasherInterface::class, beforeMethods: ['hash' => ['pluginMethod' => 'hash', 'sortOrder' => 10]], )); - + $interceptor = makePluginInterceptor($container, $registry); $proxy = $interceptor->createProxy( PIT_HasherInterface::class, PIT_BcryptHasher::class, - new PIT_BcryptHasher() + new PIT_BcryptHasher(), ); - + $result = $proxy->hash('secret'); - + expect(PIT_HasherPlugin::$callLog)->toBe(['PIT_HasherPlugin::hash(secret)']) ->and(PIT_BcryptHasher::$callLog)->toBe(['PIT_BcryptHasher::hash(secret)']) ->and($result)->toBe('bcrypt:secret'); - } + }, ); it('exposes original target via getPluginTarget', function (): void { @@ -1040,8 +1029,8 @@ function (): void { fn () => $interceptor->createProxy( PIT_ReadonlyService::class, PIT_ReadonlyService::class, - new PIT_ReadonlyService() - ) + new PIT_ReadonlyService(), + ), ) ->toThrow(PluginException::class); }); diff --git a/packages/database-mysql/tests/Query/MySqlQueryBuilderAggregatesTest.php b/packages/database-mysql/tests/Query/MySqlQueryBuilderAggregatesTest.php index 5f56f393..3e4f1b74 100644 --- a/packages/database-mysql/tests/Query/MySqlQueryBuilderAggregatesTest.php +++ b/packages/database-mysql/tests/Query/MySqlQueryBuilderAggregatesTest.php @@ -176,7 +176,7 @@ function (): void { ->toThrow(InvalidColumnException::class) ->and(fn () => $this->builder->table('scores')->avg('/*bad*/')) ->toThrow(InvalidColumnException::class); - } + }, ); it( @@ -186,7 +186,7 @@ function (): void { $method = $reflection->getMethod('count'); $returnType = $method->getReturnType(); $params = $method->getParameters(); - + expect($returnType?->getName())->toBe('int') ->and($returnType?->allowsNull())->toBeFalse() ->and($params)->toHaveCount(1) @@ -194,6 +194,6 @@ function (): void { ->and($params[0]->isOptional())->toBeTrue() ->and($params[0]->allowsNull())->toBeTrue() ->and($params[0]->getDefaultValue())->toBeNull(); - } + }, ); }); diff --git a/packages/database-mysql/tests/Query/MySqlQueryBuilderGroupByTest.php b/packages/database-mysql/tests/Query/MySqlQueryBuilderGroupByTest.php index 4de5d546..b5282c12 100644 --- a/packages/database-mysql/tests/Query/MySqlQueryBuilderGroupByTest.php +++ b/packages/database-mysql/tests/Query/MySqlQueryBuilderGroupByTest.php @@ -32,8 +32,7 @@ public function isConnected(): bool public function query( string $sql, array $bindings = [], - ): array - { + ): array { $this->lastSql = $sql; $this->lastBindings = $bindings; @@ -43,8 +42,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { return 0; } @@ -169,7 +167,7 @@ function (): void { $sql = ''; $bindings = []; $conn = makeRecordingConnection($sql, $bindings); - + expect( fn () => (new MySqlQueryBuilder($conn)) ->table('orders') @@ -177,7 +175,7 @@ function (): void { ->groupBy('status; DROP TABLE orders--') ->get(), )->toThrow(InvalidColumnException::class); - } + }, ); it('rejects HAVING expressions containing semicolons or SQL comments', function (): void { @@ -219,7 +217,7 @@ function (): void { $sql = ''; $bindings = []; $conn = makeRecordingConnection($sql, $bindings); - + (new MySqlQueryBuilder($conn)) ->table('orders') ->select('status', 'country') @@ -227,11 +225,11 @@ function (): void { ->groupBy('status', 'country') ->having('COUNT(*) BETWEEN ? AND ?', [3, 10]) ->get(); - + expect($sql)->toBe( 'SELECT `status`, `country` FROM `orders` WHERE `active` = ? GROUP BY `status`, `country` HAVING COUNT(*) BETWEEN ? AND ?', ) ->and($bindings)->toBe([1, 3, 10]); - } + }, ); }); diff --git a/packages/database/src/Entity/EntityCollection.php b/packages/database/src/Entity/EntityCollection.php index c21ecb46..eff21661 100644 --- a/packages/database/src/Entity/EntityCollection.php +++ b/packages/database/src/Entity/EntityCollection.php @@ -118,8 +118,7 @@ public function pluck(string $property): array public function sortBy( string $property, bool $descending = false, - ): self - { + ): self { $sorted = $this->entities; usort($sorted, function (Entity $a, Entity $b) use ($property, $descending): int { $result = $a->$property <=> $b->$property; diff --git a/packages/database/src/Entity/EntityCompanionStorage.php b/packages/database/src/Entity/EntityCompanionStorage.php index 5e99eec3..83d330fb 100644 --- a/packages/database/src/Entity/EntityCompanionStorage.php +++ b/packages/database/src/Entity/EntityCompanionStorage.php @@ -70,8 +70,7 @@ public function get(Entity $entity): array public function attach( Entity $entity, Entity $companion, - ): void - { + ): void { $bag = $this->companions[$entity] ?? []; $bag[$companion::class] = $companion; $this->companions[$entity] = $bag; diff --git a/packages/database/src/Entity/EntityHydrator.php b/packages/database/src/Entity/EntityHydrator.php index 5ad3a0fb..e8e40341 100644 --- a/packages/database/src/Entity/EntityHydrator.php +++ b/packages/database/src/Entity/EntityHydrator.php @@ -238,8 +238,7 @@ public function registerOriginalValues( public function attachCompanion( Entity $entity, Entity $companion, - ): void - { + ): void { EntityCompanionStorage::instance()->attach($entity, $companion); } diff --git a/packages/database/src/Entity/RelationshipLoader.php b/packages/database/src/Entity/RelationshipLoader.php index 3812f5ac..89ef3417 100644 --- a/packages/database/src/Entity/RelationshipLoader.php +++ b/packages/database/src/Entity/RelationshipLoader.php @@ -413,8 +413,7 @@ private function loadBelongsToMany( private function collectPropertyValues( array $entities, string $propertyName, - ): array - { + ): array { return array_map(fn (Entity $entity) => $this->getPropertyValue($entity, $propertyName), $entities); } @@ -424,8 +423,7 @@ private function collectPropertyValues( private function getPropertyValue( Entity $entity, string $propertyName, - ): mixed - { + ): mixed { $reflection = new ReflectionClass($entity); $property = $reflection->getProperty($propertyName); @@ -443,8 +441,7 @@ private function setProperty( Entity $entity, string $propertyName, mixed $value, - ): void - { + ): void { $reflection = new ReflectionClass($entity); $property = $reflection->getProperty($propertyName); @@ -468,8 +465,7 @@ private function setPropertyOnAll( array $entities, string $propertyName, mixed $value, - ): void - { + ): void { foreach ($entities as $entity) { $this->setProperty($entity, $propertyName, $value); } diff --git a/packages/database/src/Schema/SchemaRegistry.php b/packages/database/src/Schema/SchemaRegistry.php index 9bff39e1..ea4a12c8 100644 --- a/packages/database/src/Schema/SchemaRegistry.php +++ b/packages/database/src/Schema/SchemaRegistry.php @@ -170,7 +170,7 @@ public function registerEntities( // Merge foreign keys (use parent table name for FK name generation) foreach ($this->schemaBuilder->buildForeignKeysForTable( $parentMetadata->tableName, - $extenderMetadata->columns + $extenderMetadata->columns, ) as $fk) { $table = $table->withForeignKey($fk); } diff --git a/packages/database/tests/Attributes/RelationshipAttributeTest.php b/packages/database/tests/Attributes/RelationshipAttributeTest.php index 65452f96..d50a28a2 100644 --- a/packages/database/tests/Attributes/RelationshipAttributeTest.php +++ b/packages/database/tests/Attributes/RelationshipAttributeTest.php @@ -25,7 +25,7 @@ class RelUserEntity extends Entity RelRoleEntity::class, pivotClass: RelUserRoleEntity::class, foreignKey: 'user_id', - relatedKey: 'role_id' + relatedKey: 'role_id', )] public array $roles = []; } diff --git a/packages/database/tests/Entity/EntityCollectionTest.php b/packages/database/tests/Entity/EntityCollectionTest.php index 939325b9..de26938c 100644 --- a/packages/database/tests/Entity/EntityCollectionTest.php +++ b/packages/database/tests/Entity/EntityCollectionTest.php @@ -161,7 +161,7 @@ function makeEntity(int $id, string $name, ?string $role = null): Entity $collection = new EntityCollection([makeEntity(1, 'Alice'), makeEntity(2, 'Bob')]); expect( - $collection->contains(fn (Entity $e): bool => $e->name === 'Charlie') + $collection->contains(fn (Entity $e): bool => $e->name === 'Charlie'), )->toBeFalse(); // @phpstan-ignore-line }); }); diff --git a/packages/database/tests/Entity/EntityHydratorTest.php b/packages/database/tests/Entity/EntityHydratorTest.php index 676d4389..dbe957ad 100644 --- a/packages/database/tests/Entity/EntityHydratorTest.php +++ b/packages/database/tests/Entity/EntityHydratorTest.php @@ -664,7 +664,7 @@ public function parse(string $entityClass): EntityMetadata expect($entity->companions())->toHaveCount(2) ->and($entity->companion(HydratorTestProductExt::class))->toBeInstanceOf(HydratorTestProductExt::class) ->and($entity->companion(HydratorTestProductPricing::class))->toBeInstanceOf( - HydratorTestProductPricing::class + HydratorTestProductPricing::class, ); }); @@ -821,55 +821,55 @@ public function parse(string $entityClass): EntityMetadata 'does not require the EntityMetadataFactory call for entities without extenders (no extra parse)', function (): void { // Factory with no entries — if parse() is called it will throw a key error - $factory = createStubMetadataFactory([]); + $factory = createStubMetadataFactory([]); $hydrator = new EntityHydrator($factory); $metadata = createProductMetadata(); // extenders: [] $row = ['id' => 1, 'name' => 'Safe']; - + // Should not throw even though factory has no entries - /** @var HydratorTestProduct $entity */ + /** @var HydratorTestProduct $entity */ $entity = $hydrator->hydrate(HydratorTestProduct::class, $row, $metadata); - + expect($entity)->toBeInstanceOf(HydratorTestProduct::class); - } + }, ); it( 'constructs without the EntityMetadataFactory and hydrates non-extended entities correctly (backward compat)', function (): void { $hydrator = new EntityHydrator(); // no factory - $metadata = createProductMetadata(); // extenders: [] + $metadata = createProductMetadata(); // extenders: [] $row = ['id' => 10, 'name' => 'Compat']; /** @var HydratorTestProduct $entity */ $entity = $hydrator->hydrate(HydratorTestProduct::class, $row, $metadata); - + expect($entity)->toBeInstanceOf(HydratorTestProduct::class) ->and($entity->id)->toBe(10) ->and($entity->name)->toBe('Compat'); - } + }, ); it( 'correctly hydrates companions when the factory has not seen the extender classes before (on-demand parse)', function (): void { // Use a real EntityMetadataFactory — it has never parsed HydratorTestProductExt before - $factory = new EntityMetadataFactory(); + $factory = new EntityMetadataFactory(); $hydrator = new EntityHydrator($factory); $metadata = createProductMetadataWithExtenders(HydratorTestProductExt::class); - + $row = ['id' => 4, 'name' => 'Fresh', 'sku' => 'FRS-04', 'stock' => 7]; /** @var HydratorTestProduct $entity */ $entity = $hydrator->hydrate(HydratorTestProduct::class, $row, $metadata); - + /** @var HydratorTestProductExt $ext */ $ext = $entity->companion(HydratorTestProductExt::class); - + expect($ext)->toBeInstanceOf(HydratorTestProductExt::class) ->and($ext->sku)->toBe('FRS-04') ->and($ext->stock)->toBe(7); - } + }, ); // ------------------------------------------------------------------------- diff --git a/packages/database/tests/Entity/EntityMetadataFactoryRelationshipTest.php b/packages/database/tests/Entity/EntityMetadataFactoryRelationshipTest.php index c4c3000c..7c654c84 100644 --- a/packages/database/tests/Entity/EntityMetadataFactoryRelationshipTest.php +++ b/packages/database/tests/Entity/EntityMetadataFactoryRelationshipTest.php @@ -307,7 +307,7 @@ entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', - relatedKey: 'role_id' + relatedKey: 'role_id', )] public array $roles = []; }; @@ -327,7 +327,7 @@ entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', - relatedKey: 'role_id' + relatedKey: 'role_id', )] public array $roles = []; }; @@ -347,7 +347,7 @@ entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', - relatedKey: 'role_id' + relatedKey: 'role_id', )] public array $roles = []; }; @@ -367,7 +367,7 @@ entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', - relatedKey: 'role_id' + relatedKey: 'role_id', )] public array $roles = []; }; @@ -387,7 +387,7 @@ entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', - relatedKey: 'role_id' + relatedKey: 'role_id', )] public array $roles = []; }; @@ -407,7 +407,7 @@ entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', - relatedKey: 'role_id' + relatedKey: 'role_id', )] public array $roles = []; }; @@ -484,7 +484,7 @@ entityClass: Entity::class, pivotClass: Entity::class, foreignKey: 'user_id', - relatedKey: 'role_id' + relatedKey: 'role_id', )] public EntityCollection $roles; }; diff --git a/packages/database/tests/Entity/EntityTest.php b/packages/database/tests/Entity/EntityTest.php index 5a1500db..7f238669 100644 --- a/packages/database/tests/Entity/EntityTest.php +++ b/packages/database/tests/Entity/EntityTest.php @@ -147,14 +147,14 @@ function (): void { $parent = new ParentEntity(); $companionViaEntity = new CompanionEntity(); $companionViaHydrator = new AnotherCompanionEntity(); - + // Attach one via Entity public API, one via hydrator internal API - $parent->attachCompanion($companionViaEntity); + $parent->attachCompanion($companionViaEntity); $hydrator->attachCompanion($parent, $companionViaHydrator); - + // Both are visible through the same Entity::companions() call - expect($parent->companions())->toHaveCount(2) - ->and($parent->companion(CompanionEntity::class))->toBe($companionViaEntity) - ->and($parent->companion(AnotherCompanionEntity::class))->toBe($companionViaHydrator); - } + expect($parent->companions())->toHaveCount(2) + ->and($parent->companion(CompanionEntity::class))->toBe($companionViaEntity) + ->and($parent->companion(AnotherCompanionEntity::class))->toBe($companionViaHydrator); + }, ); diff --git a/packages/database/tests/KnownDriversValidationTest.php b/packages/database/tests/KnownDriversValidationTest.php index 3f32e2a1..8f45e47d 100644 --- a/packages/database/tests/KnownDriversValidationTest.php +++ b/packages/database/tests/KnownDriversValidationTest.php @@ -11,7 +11,7 @@ 'skeleton suggest block contains all database drivers', function () use ($knownDriversPath, $skeletonComposerPath): void { KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); - } + }, ); test('every database driver follows marko slash prefix pattern', function () use ($knownDriversPath): void { diff --git a/packages/database/tests/Schema/SchemaRegistryTest.php b/packages/database/tests/Schema/SchemaRegistryTest.php index f321f9bc..e6a12d0f 100644 --- a/packages/database/tests/Schema/SchemaRegistryTest.php +++ b/packages/database/tests/Schema/SchemaRegistryTest.php @@ -299,38 +299,38 @@ 'updates the EntityMetadataFactory cache so that a subsequent parse(parentClass) returns metadata with extenders populated', function (): void { $this->registry->registerEntities([ProductEntity::class, ProductExtenderEntity::class]); - + $cachedMetadata = $this->metadataFactory->parse(ProductEntity::class); - + expect($cachedMetadata->extenders)->toContain(ProductExtenderEntity::class); - } + }, ); it( 'handles registration order independence when an extender appears before its parent in the input array', function (): void { // Extender listed first, parent second — must still merge correctly - $this->registry->registerEntities([ProductSecondExtenderEntity::class, ProductEntity::class]); - + $this->registry->registerEntities([ProductSecondExtenderEntity::class, ProductEntity::class]); + $table = $this->registry->getTable('products'); $columnNames = array_map(fn ($c) => $c->name, $table->columns); - + expect($table)->not->toBeNull() ->and($columnNames)->toContain('barcode'); - } + }, ); it( 'includes a discovered extender from EntityDiscovery in the merged table (regression test for discovery integration)', function (): void { // Both ProductEntity and ProductExtenderEntity extend Entity with #[Table], - // so EntityDiscovery would find both. Simulate by passing both to registerEntities. - $this->registry->registerEntities([ProductEntity::class, ProductExtenderEntity::class]); - + // so EntityDiscovery would find both. Simulate by passing both to registerEntities. + $this->registry->registerEntities([ProductEntity::class, ProductExtenderEntity::class]); + $table = $this->registry->getTable('products'); - + expect($table)->not->toBeNull() ->and($table->columns)->toHaveCount(3) ->and($this->registry->getEntityClass('products'))->toBe(ProductEntity::class); - } + }, ); diff --git a/packages/devai/src/Commands/UpdateCommand.php b/packages/devai/src/Commands/UpdateCommand.php index afa48e32..1a605dc2 100644 --- a/packages/devai/src/Commands/UpdateCommand.php +++ b/packages/devai/src/Commands/UpdateCommand.php @@ -24,8 +24,7 @@ public function __construct( public function execute( Input $input, Output $output, - ): int - { + ): int { $projectRoot = (string) getcwd(); $marker = $projectRoot . '/.marko/devai.json'; diff --git a/packages/devai/src/Process/ConfirmationPrompterInterface.php b/packages/devai/src/Process/ConfirmationPrompterInterface.php index 181d9075..651989f8 100644 --- a/packages/devai/src/Process/ConfirmationPrompterInterface.php +++ b/packages/devai/src/Process/ConfirmationPrompterInterface.php @@ -8,5 +8,8 @@ interface ConfirmationPrompterInterface { public function isInteractive(): bool; - public function confirm(string $question, bool $default): bool; + public function confirm( + string $question, + bool $default, + ): bool; } diff --git a/packages/devai/tests/Helpers.php b/packages/devai/tests/Helpers.php index 06c01e2e..b62f3860 100644 --- a/packages/devai/tests/Helpers.php +++ b/packages/devai/tests/Helpers.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Marko\CodeIndexer\Module\ModuleWalker; +use Marko\CodeIndexer\ValueObject\ModuleInfo; use Marko\DevAi\Installation\InstallationContext; use Marko\DevAi\Process\CommandRunnerInterface; use Marko\DevAi\ValueObject\GuidelinesContent; @@ -45,7 +46,8 @@ function devaiRemoveDir(string $dir): void */ function devaiRunner(bool $onPath = false, string $listOutput = ''): CommandRunnerInterface { - return new class ($onPath, $listOutput) implements CommandRunnerInterface { + return new class ($onPath, $listOutput) implements CommandRunnerInterface + { /** @var list}> */ public array $calls = []; @@ -57,8 +59,7 @@ public function __construct( public function run( string $command, array $args = [], - ): array - { + ): array { $this->calls[] = ['command' => $command, 'args' => $args]; if (($args[0] ?? '') === 'mcp' && ($args[1] ?? '') === 'list') { return ['exitCode' => 0, 'stdout' => $this->listOutput, 'stderr' => '']; @@ -106,12 +107,13 @@ function devaiContext( * ModuleWalkerInterface was removed (#97); doubles now extend the concrete * ModuleWalker and override walk(). * - * @param list<\Marko\CodeIndexer\ValueObject\ModuleInfo> $modules + * @param list $modules */ function devaiWalker(array $modules = []): ModuleWalker { - return new class ($modules) extends ModuleWalker { - /** @param list<\Marko\CodeIndexer\ValueObject\ModuleInfo> $modules */ + return new class ($modules) extends ModuleWalker + { + /** @param list $modules */ public function __construct(private array $modules) {} public function walk(): array diff --git a/packages/devai/tests/Integration/ClaudeCodeInstallMonorepoTest.php b/packages/devai/tests/Integration/ClaudeCodeInstallMonorepoTest.php index f4cbefc6..2996d43f 100644 --- a/packages/devai/tests/Integration/ClaudeCodeInstallMonorepoTest.php +++ b/packages/devai/tests/Integration/ClaudeCodeInstallMonorepoTest.php @@ -35,8 +35,7 @@ public function __construct( public function run( string $cmd, array $args = [], - ): array - { + ): array { $this->calls[] = [$cmd, $args]; if ($cmd === 'claude' && ($args[0] ?? '') === 'mcp' && ($args[1] ?? '') === 'list') { return ['exitCode' => 0, 'stdout' => $this->listOutput, 'stderr' => '']; @@ -99,8 +98,7 @@ public function __construct(public string $listOutput) {} public function run( string $cmd, array $args = [], - ): array - { + ): array { $this->calls[] = [$cmd, $args]; if ($cmd === 'claude' && ($args[0] ?? '') === 'mcp' && ($args[1] ?? '') === 'list') { return ['exitCode' => 0, 'stdout' => $this->listOutput, 'stderr' => '']; @@ -184,12 +182,12 @@ function integMonorepoRunInstall( 'monorepo fixture (with stub packages/claude-plugins) produces settings.json with the path/local source shape per Task 001', function (): void { integMonorepoRunInstall($this->root); - + $data = json_decode((string) file_get_contents($this->root . '/.claude/settings.json'), true); $source = $data['extraKnownMarketplaces']['marko']['source']; expect($source['source'])->toBe('local') ->and($source['path'])->toBe('.'); - } + }, ); it('monorepo install still creates AGENTS.md at the project root', function (): void { @@ -258,7 +256,7 @@ function (): void { try { integMonorepoRunInstall($externalRoot); - + $data = json_decode((string) file_get_contents($externalRoot . '/.claude/settings.json'), true); $source = $data['extraKnownMarketplaces']['marko']['source']; expect($source['source'])->toBe('github') @@ -266,7 +264,7 @@ function (): void { } finally { integMonorepoRemoveTempDir($externalRoot); } - } + }, ); // --------------------------------------------------------------------------- @@ -289,9 +287,9 @@ function (): void { $runner = integMonorepoRunnerWithLsp(intelephenseOnPath: false, npmOnPath: true); $orchestrator = integMonorepoOrchestrator($runner); $ctx = new InstallationContext(selectedAgents: ['claude-code'], skipLspDeps: false); - + $orchestrator->install($ctx, $this->root); - + $npmInstallCalls = array_filter( $runner->calls, fn ($call) => $call[0] === 'npm' @@ -299,7 +297,7 @@ function (): void { && in_array('intelephense', $call[1], true), ); expect(array_values($npmInstallCalls))->not->toBeEmpty(); - } + }, ); it('skips intelephense installation when --skip-lsp-deps is passed', function (): void { @@ -323,17 +321,17 @@ function (): void { $runner = integMonorepoRunnerWithLsp(intelephenseOnPath: false, npmOnPath: true); $orchestrator = integMonorepoOrchestrator($runner); $ctx = new InstallationContext(selectedAgents: ['claude-code'], skipLspDeps: true); - + $orchestrator->install($ctx, $this->root); - + $settingsPath = $this->root . '/.claude/settings.json'; expect(file_exists($settingsPath))->toBeTrue(); - + $data = json_decode((string) file_get_contents($settingsPath), true); expect($data['extraKnownMarketplaces'])->toHaveKey('marko') ->and($data['enabledPlugins']['marko-skills@marko'])->toBeTrue() ->and($data['enabledPlugins']['marko-lsp@marko'])->toBeTrue() ->and($data['enabledPlugins']['marko-mcp@marko'])->toBeTrue(); - } + }, ); }); diff --git a/packages/devai/tests/Unit/Agents/ClaudeCodeAgentTest.php b/packages/devai/tests/Unit/Agents/ClaudeCodeAgentTest.php index a51e1cec..80a664c0 100644 --- a/packages/devai/tests/Unit/Agents/ClaudeCodeAgentTest.php +++ b/packages/devai/tests/Unit/Agents/ClaudeCodeAgentTest.php @@ -182,12 +182,15 @@ function (): void { }, ); - it('writes CLAUDE.md instructing the agent to trust its own writes rather than using introspection tools to confirm scaffolding', function (): void { - $this->agent->install(devaiContext('body'), $this->root); + it( + 'writes CLAUDE.md instructing the agent to trust its own writes rather than using introspection tools to confirm scaffolding', + function (): void { + $this->agent->install(devaiContext('body'), $this->root); - $claudeMd = (string) file_get_contents($this->root . '/CLAUDE.md'); - expect($claudeMd)->toContain('introspection tools are for discovering pre-existing code'); - }); + $claudeMd = (string) file_get_contents($this->root . '/CLAUDE.md'); + expect($claudeMd)->toContain('introspection tools are for discovering pre-existing code'); + }, + ); }); // --------------------------------------------------------------------------- diff --git a/packages/devai/tests/Unit/Installation/IntelephenseEnsurerTest.php b/packages/devai/tests/Unit/Installation/IntelephenseEnsurerTest.php index 8e05402b..70534b65 100644 --- a/packages/devai/tests/Unit/Installation/IntelephenseEnsurerTest.php +++ b/packages/devai/tests/Unit/Installation/IntelephenseEnsurerTest.php @@ -31,8 +31,7 @@ public function __construct( public function run( string $command, array $args = [], - ): array - { + ): array { $this->calls[] = [$command, $args]; if ($command === 'npm') { diff --git a/packages/devai/tests/Unit/Skills/SkillsDistributorTest.php b/packages/devai/tests/Unit/Skills/SkillsDistributorTest.php index 6a41d01c..8b69f30e 100644 --- a/packages/devai/tests/Unit/Skills/SkillsDistributorTest.php +++ b/packages/devai/tests/Unit/Skills/SkillsDistributorTest.php @@ -257,118 +257,118 @@ function makeSkillDir(string $base, string $skillName, array $files = []): void 'CodexAgent installer reads skill source from packages/claude-plugins/plugins/marko-skills/skills/ (not the legacy path)', function (): void { $projectRoot = dirname(dirname(__DIR__, 3), 2); - + $walker = makeWalker([]); $distributor = new SkillsDistributor($walker, $projectRoot); $bundles = $distributor->collect(); - + $allSkillKeys = []; foreach ($bundles as $bundle) { $allSkillKeys = array_merge($allSkillKeys, array_keys($bundle->skills)); } - + // Skills must come from the new canonical path, not resources/ai/skills - expect($allSkillKeys)->toContain('create-module/SKILL.md') - ->and($allSkillKeys)->not->toContain('marko-create-module/SKILL.md'); - } + expect($allSkillKeys)->toContain('create-module/SKILL.md') + ->and($allSkillKeys)->not->toContain('marko-create-module/SKILL.md'); + }, ); it( 'CursorAgent installer reads skill source from packages/claude-plugins/plugins/marko-skills/skills/', function (): void { $projectRoot = dirname(dirname(__DIR__, 3), 2); - + $walker = makeWalker([]); $distributor = new SkillsDistributor($walker, $projectRoot); $bundles = $distributor->collect(); - + $allSkillKeys = []; foreach ($bundles as $bundle) { $allSkillKeys = array_merge($allSkillKeys, array_keys($bundle->skills)); } - + expect($allSkillKeys)->toContain('create-module/SKILL.md') ->and($allSkillKeys)->toContain('create-plugin/SKILL.md'); - } + }, ); it( 'CopilotAgent installer reads skill source from packages/claude-plugins/plugins/marko-skills/skills/', function (): void { $projectRoot = dirname(dirname(__DIR__, 3), 2); - + $walker = makeWalker([]); $distributor = new SkillsDistributor($walker, $projectRoot); $bundles = $distributor->collect(); - + $allSkillKeys = []; foreach ($bundles as $bundle) { $allSkillKeys = array_merge($allSkillKeys, array_keys($bundle->skills)); } - + expect($allSkillKeys)->toContain('create-module/SKILL.md') ->and($allSkillKeys)->toContain('create-plugin/SKILL.md'); - } + }, ); it( 'GeminiCliAgent installer reads skill source from packages/claude-plugins/plugins/marko-skills/skills/', function (): void { $projectRoot = dirname(dirname(__DIR__, 3), 2); - + $walker = makeWalker([]); $distributor = new SkillsDistributor($walker, $projectRoot); $bundles = $distributor->collect(); - + $allSkillKeys = []; foreach ($bundles as $bundle) { $allSkillKeys = array_merge($allSkillKeys, array_keys($bundle->skills)); } - + expect($allSkillKeys)->toContain('create-module/SKILL.md') ->and($allSkillKeys)->toContain('create-plugin/SKILL.md'); - } + }, ); it( 'JunieAgent installer reads skill source from packages/claude-plugins/plugins/marko-skills/skills/', function (): void { $projectRoot = dirname(dirname(__DIR__, 3), 2); - + $walker = makeWalker([]); $distributor = new SkillsDistributor($walker, $projectRoot); $bundles = $distributor->collect(); - + $allSkillKeys = []; foreach ($bundles as $bundle) { $allSkillKeys = array_merge($allSkillKeys, array_keys($bundle->skills)); } - + expect($allSkillKeys)->toContain('create-module/SKILL.md') ->and($allSkillKeys)->toContain('create-plugin/SKILL.md'); - } + }, ); it( 'each agent copies the entire skill directory (SKILL.md plus assets/ and references/) so template references resolve', function (): void { $projectRoot = dirname(dirname(__DIR__, 3), 2); - + $walker = makeWalker([]); $distributor = new SkillsDistributor($walker, $projectRoot); $bundles = $distributor->collect(); - + $allSkillKeys = []; foreach ($bundles as $bundle) { $allSkillKeys = array_merge($allSkillKeys, array_keys($bundle->skills)); } - + // Assets from create-module are bundled - expect($allSkillKeys)->toContain('create-module/SKILL.md') - ->and($allSkillKeys)->toContain('create-module/assets/module.php.tmpl') - ->and($allSkillKeys)->toContain('create-plugin/SKILL.md') - ->and($allSkillKeys)->toContain('create-plugin/assets/PluginClass.php.tmpl'); - } + expect($allSkillKeys)->toContain('create-module/SKILL.md') + ->and($allSkillKeys)->toContain('create-module/assets/module.php.tmpl') + ->and($allSkillKeys)->toContain('create-plugin/SKILL.md') + ->and($allSkillKeys)->toContain('create-plugin/assets/PluginClass.php.tmpl'); + }, ); it( @@ -376,9 +376,9 @@ function (): void { function (): void { $devaiPackageRoot = dirname(__DIR__, 3); $legacySkillsDir = $devaiPackageRoot . '/resources/ai/skills'; - + expect(is_dir($legacySkillsDir))->toBeFalse(); - } + }, ); it('no duplicate skill content exists across the codebase — grep verification', function (): void { @@ -391,7 +391,7 @@ function (): void { function (): void { $srcDir = dirname(__DIR__, 3) . '/src'; $found = []; - + $iter = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($srcDir, RecursiveDirectoryIterator::SKIP_DOTS), ); @@ -401,21 +401,21 @@ function (): void { } $content = (string) file_get_contents($file->getPathname()); // The only permitted occurrence is the MODULE_SKILLS_REL_PATH constant in SkillsDistributor - // (which scans third-party modules). Any other file having this string is a legacy reference. - $occurrences = substr_count($content, 'resources/ai/skills'); + // (which scans third-party modules). Any other file having this string is a legacy reference. + $occurrences = substr_count($content, 'resources/ai/skills'); if ($occurrences === 0) { continue; } // SkillsDistributor is allowed exactly one occurrence (the MODULE_SKILLS_REL_PATH constant) - $basename = basename($file->getPathname()); + $basename = basename($file->getPathname()); if ($basename === 'SkillsDistributor.php' && $occurrences === 1) { continue; } $found[] = $file->getPathname(); } - + expect($found)->toBe([]); - } + }, ); it('skips a skill directory missing SKILL.md and records a warning', function (): void { diff --git a/packages/devai/tests/Unit/Writing/GuidelinesWriterTest.php b/packages/devai/tests/Unit/Writing/GuidelinesWriterTest.php index cc79d991..14643a37 100644 --- a/packages/devai/tests/Unit/Writing/GuidelinesWriterTest.php +++ b/packages/devai/tests/Unit/Writing/GuidelinesWriterTest.php @@ -127,25 +127,28 @@ devaiRemoveDir($tmpDir); }); -it('returns skipped and leaves the file untouched when a begin marker exists but the end marker is missing', function (): void { - $tmpDir = devaiTempDir(); - $path = $tmpDir . '/GUIDELINES.md'; +it( + 'returns skipped and leaves the file untouched when a begin marker exists but the end marker is missing', + function (): void { + $tmpDir = devaiTempDir(); + $path = $tmpDir . '/GUIDELINES.md'; - $malformed = "# Header\n" . GuidelinesWriter::MARKER_BEGIN . "\nOrphaned content with no end marker.\n"; - file_put_contents($path, $malformed); + $malformed = "# Header\n" . GuidelinesWriter::MARKER_BEGIN . "\nOrphaned content with no end marker.\n"; + file_put_contents($path, $malformed); - // Drain any prior notices - GuidelinesWriter::takeNotices(); + // Drain any prior notices + GuidelinesWriter::takeNotices(); - $outcome = GuidelinesWriter::write($path, 'New content'); + $outcome = GuidelinesWriter::write($path, 'New content'); - $contents = (string) file_get_contents($path); + $contents = (string) file_get_contents($path); - expect($outcome)->toBe(WriteOutcome::SkippedNoMarkers) - ->and($contents)->toBe($malformed); + expect($outcome)->toBe(WriteOutcome::SkippedNoMarkers) + ->and($contents)->toBe($malformed); - devaiRemoveDir($tmpDir); -}); + devaiRemoveDir($tmpDir); + }, +); it('embeds the marko devai:update regenerate hint inside the wrapped region', function (): void { $tmpDir = devaiTempDir(); @@ -157,7 +160,11 @@ $beginPos = strpos($contents, GuidelinesWriter::MARKER_BEGIN); $endPos = strpos($contents, GuidelinesWriter::MARKER_END); - $wrappedRegion = substr($contents, (int) $beginPos, (int) $endPos - (int) $beginPos + strlen(GuidelinesWriter::MARKER_END)); + $wrappedRegion = substr( + $contents, + (int) $beginPos, + (int) $endPos - (int) $beginPos + strlen(GuidelinesWriter::MARKER_END), + ); expect($wrappedRegion)->toContain('marko devai:update'); diff --git a/packages/devserver/src/Exceptions/DevServerException.php b/packages/devserver/src/Exceptions/DevServerException.php index 4009cf98..db1bf9c0 100644 --- a/packages/devserver/src/Exceptions/DevServerException.php +++ b/packages/devserver/src/Exceptions/DevServerException.php @@ -11,8 +11,7 @@ class DevServerException extends MarkoException public static function processFailedToStart( string $name, string $command, - ): self - { + ): self { return new self( message: "Failed to start process '$name' with command: $command", context: 'While starting development services', diff --git a/packages/devserver/tests/Process/PidFileTest.php b/packages/devserver/tests/Process/PidFileTest.php index 7d6ee3e9..dc3d8ab4 100644 --- a/packages/devserver/tests/Process/PidFileTest.php +++ b/packages/devserver/tests/Process/PidFileTest.php @@ -131,6 +131,10 @@ function removeDir(string $dir): void }); it('detects a process group as running when parent died but child lives', function (): void { + if (!function_exists('pcntl_fork') || !function_exists('posix_setsid')) { + test()->markTestSkipped('pcntl and posix are required to build a detached process group'); + } + $tmpDir = sys_get_temp_dir() . '/pid-file-test-' . uniqid(); mkdir($tmpDir, 0755, true); $pidFile = new PidFile($tmpDir); @@ -149,8 +153,11 @@ function removeDir(string $dir): void exit(0); PHP; $encoded = base64_encode($script); + // Array form execs directly. A string command goes through /bin/sh, so the reported pid + // would be the shell's rather than PHP's — macOS sh execs in place and hides that, dash + // does not, and posix_setsid() then builds the group under a pid the assertions never see. $proc = proc_open( - "$php -r 'eval(base64_decode(\"$encoded\"));'", + [$php, '-r', 'eval(base64_decode("' . $encoded . '"));'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, ); diff --git a/packages/docs-fts/src/Indexing/FtsIndexBuilder.php b/packages/docs-fts/src/Indexing/FtsIndexBuilder.php index 8414c0a2..53c30092 100644 --- a/packages/docs-fts/src/Indexing/FtsIndexBuilder.php +++ b/packages/docs-fts/src/Indexing/FtsIndexBuilder.php @@ -21,7 +21,7 @@ public function build(string $outputPath): void if ($pages === []) { throw DocsException::searchFailed( - 'No pages found in MarkdownRepository — check docs-markdown is installed' + 'No pages found in MarkdownRepository — check docs-markdown is installed', ); } @@ -55,7 +55,7 @@ public function build(string $outputPath): void $insertFts = $pdo->prepare('INSERT INTO docs_fts (page_id, title, content) VALUES (:id, :title, :content)'); $insertMeta = $pdo->prepare( - 'INSERT INTO docs_meta (page_id, url, section, title, last_updated) VALUES (:id, :url, :section, :title, :ts)' + 'INSERT INTO docs_meta (page_id, url, section, title, last_updated) VALUES (:id, :url, :section, :title, :ts)', ); $pdo->beginTransaction(); @@ -82,8 +82,7 @@ public function build(string $outputPath): void private function extractTitle( string $markdown, string $fallback, - ): string - { + ): string { if (preg_match('/^---\s*\n.*?title:\s*["\']?([^"\'\n]+)["\']?.*?\n---/s', $markdown, $m)) { return trim($m[1]); } diff --git a/packages/docs-fts/tests/Unit/Indexing/FtsIndexBuilderTest.php b/packages/docs-fts/tests/Unit/Indexing/FtsIndexBuilderTest.php index a84c8cfd..b4794e13 100644 --- a/packages/docs-fts/tests/Unit/Indexing/FtsIndexBuilderTest.php +++ b/packages/docs-fts/tests/Unit/Indexing/FtsIndexBuilderTest.php @@ -97,7 +97,7 @@ function tempDbPath(): string $pdo = new PDO('sqlite:' . $dbPath); $row = $pdo->query("SELECT page_id, url, section, title FROM docs_meta WHERE page_id = 'guide/install'")->fetch( - PDO::FETCH_ASSOC + PDO::FETCH_ASSOC, ); expect($row)->toBeArray() @@ -122,7 +122,7 @@ function tempDbPath(): string $pdo = new PDO('sqlite:' . $dbPath); $results = $pdo->query("SELECT page_id FROM docs_fts WHERE docs_fts MATCH 'install' ORDER BY rank")->fetchAll( - PDO::FETCH_COLUMN + PDO::FETCH_COLUMN, ); expect($results)->not->toBeEmpty() @@ -190,10 +190,10 @@ function tempDbPath(): string function (): void { $reflection = new ReflectionClass(BuildIndexCommand::class); $attributes = $reflection->getAttributes(Command::class); - + expect($attributes)->toHaveCount(1) ->and($attributes[0]->newInstance()->name)->toBe('docs-fts:build'); - + expect($reflection->implementsInterface(CommandInterface::class))->toBeTrue(); - } + }, ); diff --git a/packages/docs-fts/tests/Unit/SkeletonTest.php b/packages/docs-fts/tests/Unit/SkeletonTest.php index 7bed21af..6396bb6a 100644 --- a/packages/docs-fts/tests/Unit/SkeletonTest.php +++ b/packages/docs-fts/tests/Unit/SkeletonTest.php @@ -9,12 +9,12 @@ function (): void { $composerPath = dirname(__DIR__, 2) . '/composer.json'; $composer = json_decode((string) file_get_contents($composerPath), true); - + expect(file_exists($composerPath))->toBeTrue() ->and($composer['name'])->toBe('marko/docs-fts') ->and($composer['require'])->toHaveKey('marko/docs') ->and($composer['require'])->toHaveKey('marko/docs-markdown'); - } + }, ); it('declares ext-pdo_sqlite as a required PHP extension', function (): void { diff --git a/packages/docs-markdown/tests/Unit/AstroPipelineTest.php b/packages/docs-markdown/tests/Unit/AstroPipelineTest.php index 8390c49a..57f5bd64 100644 --- a/packages/docs-markdown/tests/Unit/AstroPipelineTest.php +++ b/packages/docs-markdown/tests/Unit/AstroPipelineTest.php @@ -14,12 +14,15 @@ expect(is_link($symlinkPath))->toBeTrue(); }); -it('produces a clean build of marko.build/docs after the path change', function () use ($symlinkPath, $packageDocs): void { - $target = readlink($symlinkPath); - $resolvedTarget = realpath(dirname($symlinkPath) . '/' . $target); - - expect($resolvedTarget)->toBe(realpath($packageDocs)); -}); +it( + 'produces a clean build of marko.build/docs after the path change', + function () use ($symlinkPath, $packageDocs): void { + $target = readlink($symlinkPath); + $resolvedTarget = realpath(dirname($symlinkPath) . '/' . $target); + + expect($resolvedTarget)->toBe(realpath($packageDocs)); + }, +); it('renders the same pages count as before the migration', function () use ($symlinkPath): void { // Count .md and .mdx files reachable through the symlinked source. diff --git a/packages/encryption/tests/KnownDriversValidationTest.php b/packages/encryption/tests/KnownDriversValidationTest.php index 290c0eb7..80afc060 100644 --- a/packages/encryption/tests/KnownDriversValidationTest.php +++ b/packages/encryption/tests/KnownDriversValidationTest.php @@ -11,7 +11,7 @@ 'skeleton suggest block contains all encryption drivers', function () use ($knownDriversPath, $skeletonComposerPath) { KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); - } + }, ); test('every encryption driver follows marko slash prefix pattern', function () use ($knownDriversPath) { diff --git a/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php b/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php index 68758869..23fb0d07 100644 --- a/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php +++ b/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php @@ -175,12 +175,12 @@ function (): void { [$formatter, $report] = createFormatterWithReport( message: 'Visit www.example.com for help', ); - + $output = $formatter->format($report); - + expect($output)->not->toContain('toBeEmpty( - 'These require-dev constraints are not self.version: ' . implode(', ', $violations) + 'These require-dev constraints are not self.version: ' . implode(', ', $violations), ); }); @@ -91,7 +91,7 @@ function getPackageComposerFiles(): array } expect($violations)->toBeEmpty( - 'devserver has non-self.version marko/* constraints: ' . implode(', ', $violations) + 'devserver has non-self.version marko/* constraints: ' . implode(', ', $violations), ); }); @@ -136,7 +136,7 @@ function getPackageComposerFiles(): array } expect($violations)->toBeEmpty( - 'These non-marko dependencies incorrectly use self.version: ' . implode(', ', $violations) + 'These non-marko dependencies incorrectly use self.version: ' . implode(', ', $violations), ); }); @@ -172,6 +172,6 @@ function getPackageComposerFiles(): array } expect($violations)->toBeEmpty( - 'Structural violations in package composer.json files: ' . implode(', ', $violations) + 'Structural violations in package composer.json files: ' . implode(', ', $violations), ); }); diff --git a/packages/framework/tests/RootComposerJsonTest.php b/packages/framework/tests/RootComposerJsonTest.php index fd928332..e6a9b56d 100644 --- a/packages/framework/tests/RootComposerJsonTest.php +++ b/packages/framework/tests/RootComposerJsonTest.php @@ -85,38 +85,38 @@ 'adds a require section entry for all 73 marko packages set to self.version', function () use ($rootComposer, $allPackages): void { expect($rootComposer)->toHaveKey('require'); - + foreach ($allPackages as $package) { expect($rootComposer['require'])->toHaveKey($package) ->and($rootComposer['require'][$package])->toBe('self.version'); } - } + }, ); it( 'does not have a replace section (path repos install as symlinks without it)', function () use ($rootComposer): void { expect($rootComposer)->not->toHaveKey('replace'); - } + }, ); it( 'adds repositories section with path repos for all 73 packages', function () use ($rootComposer, $allPackages): void { expect($rootComposer)->toHaveKey('repositories'); - + $repoUrls = array_column($rootComposer['repositories'], 'url'); - + foreach ($allPackages as $package) { $packageName = str_replace('marko/', '', $package); expect(in_array("packages/$packageName", $repoUrls, true))->toBeTrue(); } - + foreach ($rootComposer['repositories'] as $repo) { expect($repo)->toHaveKey('type') ->and($repo['type'])->toBe('path'); } - } + }, ); it('removes all manual PSR-4 autoload entries for marko packages', function () use ($rootComposer): void { @@ -135,18 +135,18 @@ function () use ($rootComposer, $allPackages): void { 'keeps autoload-dev entries for test namespaces (Composer does not merge autoload-dev from dependencies)', function () use ($rootComposer, $allPackages): void { // autoload-dev must remain in root: Composer only applies a package's autoload-dev - // when it is the root package, so test namespaces for all monorepo packages must - // be declared here to be discoverable when running the test suite. - expect($rootComposer)->toHaveKey('autoload-dev') - ->and($rootComposer['autoload-dev'])->toHaveKey('psr-4'); - + // when it is the root package, so test namespaces for all monorepo packages must + // be declared here to be discoverable when running the test suite. + expect($rootComposer)->toHaveKey('autoload-dev') + ->and($rootComposer['autoload-dev'])->toHaveKey('psr-4'); + $devPsr4 = $rootComposer['autoload-dev']['psr-4']; $hasAtLeastOneTestNamespace = array_any( array_keys($devPsr4), fn (string $ns): bool => str_ends_with($ns, 'Tests\\'), ); expect($hasAtLeastOneTestNamespace)->toBeTrue(); - } + }, ); it('removes the autoload files entry for packages/env/src/functions.php', function () use ($rootComposer): void { @@ -164,7 +164,7 @@ function () use ($rootComposer): void { ->and($rootComposer['require'])->toHaveKey('ext-fileinfo') ->and($rootComposer['require'])->toHaveKey('ext-gd') ->and($rootComposer['require'])->toHaveKey('ext-imagick'); - + $expectedDevPackages = [ 'amphp/postgres', 'amphp/redis', @@ -178,11 +178,11 @@ function () use ($rootComposer): void { 'slevomat/coding-standard', 'squizlabs/php_codesniffer', ]; - + foreach ($expectedDevPackages as $package) { expect($rootComposer['require-dev'])->toHaveKey($package); } - } + }, ); it('preserves scripts, config, and other root-level settings', function () use ($rootComposer): void { @@ -202,7 +202,7 @@ function () use ($rootComposer): void { function () use ($rootComposer): void { expect($rootComposer)->toHaveKey('minimum-stability') ->and($rootComposer['minimum-stability'])->toBe('stable'); - } + }, ); it('keeps prefer-stable as true', function () use ($rootComposer): void { diff --git a/packages/health/tests/Unit/FilesystemHealthCheckTest.php b/packages/health/tests/Unit/FilesystemHealthCheckTest.php index 67244f53..88d89157 100644 --- a/packages/health/tests/Unit/FilesystemHealthCheckTest.php +++ b/packages/health/tests/Unit/FilesystemHealthCheckTest.php @@ -48,8 +48,7 @@ public function write( string $path, string $contents, array $options = [], - ): bool - { + ): bool { $this->written[$path] = $contents; return true; @@ -59,16 +58,14 @@ public function writeStream( string $path, mixed $resource, array $options = [], - ): bool - { + ): bool { return true; } public function append( string $path, string $contents, - ): bool - { + ): bool { return true; } @@ -82,16 +79,14 @@ public function delete(string $path): bool public function copy( string $source, string $destination, - ): bool - { + ): bool { return true; } public function move( string $source, string $destination, - ): bool - { + ): bool { return true; } @@ -128,8 +123,7 @@ public function deleteDirectory(string $path): bool public function setVisibility( string $path, string $visibility, - ): bool - { + ): bool { return true; } @@ -186,8 +180,7 @@ public function write( string $path, string $contents, array $options = [], - ): bool - { + ): bool { throw new RuntimeException('Filesystem not writable'); } @@ -195,16 +188,14 @@ public function writeStream( string $path, mixed $resource, array $options = [], - ): bool - { + ): bool { return false; } public function append( string $path, string $contents, - ): bool - { + ): bool { return false; } @@ -216,16 +207,14 @@ public function delete(string $path): bool public function copy( string $source, string $destination, - ): bool - { + ): bool { return false; } public function move( string $source, string $destination, - ): bool - { + ): bool { return false; } @@ -262,8 +251,7 @@ public function deleteDirectory(string $path): bool public function setVisibility( string $path, string $visibility, - ): bool - { + ): bool { return false; } diff --git a/packages/http/tests/KnownDriversValidationTest.php b/packages/http/tests/KnownDriversValidationTest.php index c111eaee..f24614f7 100644 --- a/packages/http/tests/KnownDriversValidationTest.php +++ b/packages/http/tests/KnownDriversValidationTest.php @@ -9,9 +9,9 @@ test( 'skeleton suggest block contains all http drivers', - fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath) + fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath), ); test( 'every http driver follows marko slash prefix pattern', - fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath) + fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath), ); diff --git a/packages/inertia/tests/KnownDriversValidationTest.php b/packages/inertia/tests/KnownDriversValidationTest.php index d2d84e9c..374e742b 100644 --- a/packages/inertia/tests/KnownDriversValidationTest.php +++ b/packages/inertia/tests/KnownDriversValidationTest.php @@ -11,7 +11,7 @@ 'skeleton suggest block contains all inertia drivers', function () use ($knownDriversPath, $skeletonComposerPath) { KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); - } + }, ); test('every inertia driver follows marko slash prefix pattern', function () use ($knownDriversPath) { diff --git a/packages/layout/src/ComponentCollector.php b/packages/layout/src/ComponentCollector.php index 96bec78e..d818c7a8 100644 --- a/packages/layout/src/ComponentCollector.php +++ b/packages/layout/src/ComponentCollector.php @@ -27,8 +27,7 @@ public function __construct( public function collect( array $classNames, string $handle, - ): ComponentCollection - { + ): ComponentCollection { $collection = new ComponentCollection(); foreach ($classNames as $className) { @@ -142,8 +141,7 @@ private function resolveHandles(string|array $handle): array private function resolveClassReferenceHandle( string $controllerClass, string $action, - ): ?string - { + ): ?string { foreach ($this->routeCollection->all() as $route) { if ($route->controller === $controllerClass && $route->action === $action) { return $this->handleResolver->generate($route->path, $controllerClass, $action); diff --git a/packages/layout/src/ComponentDataResolver.php b/packages/layout/src/ComponentDataResolver.php index 906f9788..b98ae9cc 100644 --- a/packages/layout/src/ComponentDataResolver.php +++ b/packages/layout/src/ComponentDataResolver.php @@ -21,8 +21,7 @@ public function resolve( object $component, array $routeParams, Request $request, - ): array - { + ): array { if (!method_exists($component, 'data')) { return []; } @@ -54,8 +53,7 @@ public function resolve( private function castToType( mixed $value, ?ReflectionType $type, - ): mixed - { + ): mixed { if (!$type instanceof ReflectionNamedType) { return $value; } diff --git a/packages/layout/src/DiscoveringComponentCollector.php b/packages/layout/src/DiscoveringComponentCollector.php index ee9ecc86..b80da6a8 100644 --- a/packages/layout/src/DiscoveringComponentCollector.php +++ b/packages/layout/src/DiscoveringComponentCollector.php @@ -30,8 +30,7 @@ public function __construct( public function collect( array $classNames, string $handle, - ): ComponentCollection - { + ): ComponentCollection { $discovered = $this->discoverComponentClasses(); $merged = array_values(array_unique(array_merge($discovered, $classNames))); diff --git a/packages/layout/src/Exceptions/AmbiguousSortOrderException.php b/packages/layout/src/Exceptions/AmbiguousSortOrderException.php index 8513d20d..853be530 100644 --- a/packages/layout/src/Exceptions/AmbiguousSortOrderException.php +++ b/packages/layout/src/Exceptions/AmbiguousSortOrderException.php @@ -13,8 +13,7 @@ public static function forComponents( string $slot, int $sortOrder, array $components, - ): self - { + ): self { $componentList = implode(', ', $components); return new self( diff --git a/packages/layout/src/Exceptions/CircularSlotException.php b/packages/layout/src/Exceptions/CircularSlotException.php index 127d9617..1f503e87 100644 --- a/packages/layout/src/Exceptions/CircularSlotException.php +++ b/packages/layout/src/Exceptions/CircularSlotException.php @@ -12,8 +12,7 @@ class CircularSlotException extends LayoutException public static function forSlot( string $slot, array $chain, - ): self - { + ): self { $chainPath = implode(' -> ', $chain); return new self( diff --git a/packages/layout/src/Exceptions/DuplicateComponentException.php b/packages/layout/src/Exceptions/DuplicateComponentException.php index 4d55b932..4162b396 100644 --- a/packages/layout/src/Exceptions/DuplicateComponentException.php +++ b/packages/layout/src/Exceptions/DuplicateComponentException.php @@ -10,8 +10,7 @@ public static function forComponent( string $name, string $moduleA, string $moduleB, - ): self - { + ): self { return new self( message: "Component '$name' is registered in multiple modules.", context: "Component '$name' is registered in both '$moduleA' and '$moduleB'.", diff --git a/packages/layout/src/Exceptions/SlotNotFoundException.php b/packages/layout/src/Exceptions/SlotNotFoundException.php index 834385a2..cf2855ae 100644 --- a/packages/layout/src/Exceptions/SlotNotFoundException.php +++ b/packages/layout/src/Exceptions/SlotNotFoundException.php @@ -9,8 +9,7 @@ class SlotNotFoundException extends LayoutException public static function forSlot( string $slot, string $layout, - ): self - { + ): self { return new self( message: "Slot '$slot' not found in layout '$layout'.", context: "Attempted to fill slot '$slot' in layout '$layout' but the slot is not defined.", diff --git a/packages/layout/src/HandleResolver.php b/packages/layout/src/HandleResolver.php index 253b33e8..18757acf 100644 --- a/packages/layout/src/HandleResolver.php +++ b/packages/layout/src/HandleResolver.php @@ -10,8 +10,7 @@ public function generate( string $path, string $controllerClass, string $action, - ): string - { + ): string { $segments = explode('/', trim($path, '/')); $routePart = $segments[0] !== '' ? $segments[0] : 'index'; @@ -24,8 +23,7 @@ public function generate( public function matches( string $componentHandle, string $pageHandle, - ): bool - { + ): bool { if ($componentHandle === 'default') { return true; } diff --git a/packages/layout/src/LayoutProcessor.php b/packages/layout/src/LayoutProcessor.php index 97c9b71a..1763d601 100644 --- a/packages/layout/src/LayoutProcessor.php +++ b/packages/layout/src/LayoutProcessor.php @@ -127,8 +127,7 @@ private function renderSlot( private function detectCircularReferences( ComponentCollection $collection, array $topLevelSlots, - ): void - { + ): void { // Build a map: slot name -> sub-slots it leads to (via components in that slot) /** @var array> $slotToSubSlots */ $slotToSubSlots = []; diff --git a/packages/layout/src/LayoutResolver.php b/packages/layout/src/LayoutResolver.php index e03511f4..479f5f4f 100644 --- a/packages/layout/src/LayoutResolver.php +++ b/packages/layout/src/LayoutResolver.php @@ -26,8 +26,7 @@ public function resolve( string $controllerClass, string $method, - ): array - { + ): array { $classReflection = new ReflectionClass($controllerClass); $methodReflection = new ReflectionMethod($controllerClass, $method); diff --git a/packages/layout/tests/Exceptions/AmbiguousSortOrderExceptionTest.php b/packages/layout/tests/Exceptions/AmbiguousSortOrderExceptionTest.php index f10e3708..12c82bae 100644 --- a/packages/layout/tests/Exceptions/AmbiguousSortOrderExceptionTest.php +++ b/packages/layout/tests/Exceptions/AmbiguousSortOrderExceptionTest.php @@ -9,10 +9,10 @@ 'has an AmbiguousSortOrderException with a static factory method providing message, context, and suggestion', function (): void { $exception = AmbiguousSortOrderException::forComponents('header', 10, ['ComponentA', 'ComponentB']); - + expect($exception)->toBeInstanceOf(LayoutException::class) ->and($exception->getMessage())->toContain('header') ->and($exception->getContext())->not->toBeEmpty() ->and($exception->getSuggestion())->not->toBeEmpty(); - } + }, ); diff --git a/packages/layout/tests/Exceptions/CircularSlotExceptionTest.php b/packages/layout/tests/Exceptions/CircularSlotExceptionTest.php index deb97de0..9c935cc6 100644 --- a/packages/layout/tests/Exceptions/CircularSlotExceptionTest.php +++ b/packages/layout/tests/Exceptions/CircularSlotExceptionTest.php @@ -9,10 +9,10 @@ 'has a CircularSlotException with a static factory method providing message, context, and suggestion', function (): void { $exception = CircularSlotException::forSlot('main', ['main', 'nested', 'main']); - + expect($exception)->toBeInstanceOf(LayoutException::class) ->and($exception->getMessage())->toContain('main') ->and($exception->getContext())->not->toBeEmpty() ->and($exception->getSuggestion())->not->toBeEmpty(); - } + }, ); diff --git a/packages/layout/tests/Exceptions/ComponentNotFoundExceptionTest.php b/packages/layout/tests/Exceptions/ComponentNotFoundExceptionTest.php index 51b37d54..bb41cb24 100644 --- a/packages/layout/tests/Exceptions/ComponentNotFoundExceptionTest.php +++ b/packages/layout/tests/Exceptions/ComponentNotFoundExceptionTest.php @@ -9,10 +9,10 @@ 'has a ComponentNotFoundException with a static factory method providing message, context, and suggestion', function (): void { $exception = ComponentNotFoundException::forComponent('alert'); - + expect($exception)->toBeInstanceOf(LayoutException::class) ->and($exception->getMessage())->toContain('alert') ->and($exception->getContext())->not->toBeEmpty() ->and($exception->getSuggestion())->not->toBeEmpty(); - } + }, ); diff --git a/packages/layout/tests/Exceptions/DuplicateComponentExceptionTest.php b/packages/layout/tests/Exceptions/DuplicateComponentExceptionTest.php index 84d7e8c6..819468b8 100644 --- a/packages/layout/tests/Exceptions/DuplicateComponentExceptionTest.php +++ b/packages/layout/tests/Exceptions/DuplicateComponentExceptionTest.php @@ -9,10 +9,10 @@ 'has a DuplicateComponentException with a static factory method providing message, context, and suggestion', function (): void { $exception = DuplicateComponentException::forComponent('header', 'ModuleA', 'ModuleB'); - + expect($exception)->toBeInstanceOf(LayoutException::class) ->and($exception->getMessage())->toContain('header') ->and($exception->getContext())->not->toBeEmpty() ->and($exception->getSuggestion())->not->toBeEmpty(); - } + }, ); diff --git a/packages/layout/tests/Exceptions/LayoutNotFoundExceptionTest.php b/packages/layout/tests/Exceptions/LayoutNotFoundExceptionTest.php index 549d17fb..0ec94dbb 100644 --- a/packages/layout/tests/Exceptions/LayoutNotFoundExceptionTest.php +++ b/packages/layout/tests/Exceptions/LayoutNotFoundExceptionTest.php @@ -9,10 +9,10 @@ 'has a LayoutNotFoundException with a static factory method providing message, context, and suggestion', function (): void { $exception = LayoutNotFoundException::forLayout('admin'); - + expect($exception)->toBeInstanceOf(LayoutException::class) ->and($exception->getMessage())->toContain('admin') ->and($exception->getContext())->not->toBeEmpty() ->and($exception->getSuggestion())->not->toBeEmpty(); - } + }, ); diff --git a/packages/layout/tests/Exceptions/SlotNotFoundExceptionTest.php b/packages/layout/tests/Exceptions/SlotNotFoundExceptionTest.php index 0ea55b27..bed19075 100644 --- a/packages/layout/tests/Exceptions/SlotNotFoundExceptionTest.php +++ b/packages/layout/tests/Exceptions/SlotNotFoundExceptionTest.php @@ -9,10 +9,10 @@ 'has a SlotNotFoundException with a static factory method providing message, context, and suggestion', function (): void { $exception = SlotNotFoundException::forSlot('sidebar', 'main-layout'); - + expect($exception)->toBeInstanceOf(LayoutException::class) ->and($exception->getMessage())->toContain('sidebar') ->and($exception->getContext())->not->toBeEmpty() ->and($exception->getSuggestion())->not->toBeEmpty(); - } + }, ); diff --git a/packages/layout/tests/Unit/ComponentCollectorTest.php b/packages/layout/tests/Unit/ComponentCollectorTest.php index c0ab25e3..c1beffab 100644 --- a/packages/layout/tests/Unit/ComponentCollectorTest.php +++ b/packages/layout/tests/Unit/ComponentCollectorTest.php @@ -28,7 +28,7 @@ class OtherPageComponent {} #[Component( template: 'multi/component.phtml', slot: 'content', - handle: ['catalog_product_show', 'catalog_category_view'] + handle: ['catalog_product_show', 'catalog_category_view'], )] class MultiHandleComponent {} diff --git a/packages/layout/tests/Unit/LayoutProcessorNestedTest.php b/packages/layout/tests/Unit/LayoutProcessorNestedTest.php index 9301b559..9698fd86 100644 --- a/packages/layout/tests/Unit/LayoutProcessorNestedTest.php +++ b/packages/layout/tests/Unit/LayoutProcessorNestedTest.php @@ -35,7 +35,7 @@ class LpnFixtureRootComponent {} slot: 'content', handle: 'default', sortOrder: 10, - slots: ['tab.details', 'tab.reviews'] + slots: ['tab.details', 'tab.reviews'], )] class LpnFixtureTabsComponent {} @@ -64,7 +64,7 @@ class LpnFixtureNavComponent {} slot: 'main', handle: 'default', sortOrder: 10, - slots: ['body.sidebar', 'body.content'] + slots: ['body.sidebar', 'body.content'], )] class LpnFixtureBodyComponent {} @@ -80,7 +80,7 @@ class LpnFixtureMainContentComponent {} slot: 'content', handle: 'default', sortOrder: 10, - slots: ['tab.details'] + slots: ['tab.details'], )] class LpnFixtureDeepTabsComponent {} @@ -89,7 +89,7 @@ class LpnFixtureDeepTabsComponent {} slot: 'tab.details', handle: 'default', sortOrder: 10, - slots: ['detail.images', 'detail.description'] + slots: ['detail.images', 'detail.description'], )] class LpnFixtureDeepDetailsComponent {} @@ -100,7 +100,7 @@ class LpnFixtureDetailImagesComponent {} template: 'components/detail-description.html', slot: 'detail.description', handle: 'default', - sortOrder: 20 + sortOrder: 20, )] class LpnFixtureDetailDescriptionComponent {} @@ -114,7 +114,7 @@ class LpnFixtureDetailDescriptionComponent {} slot: 'content', handle: 'default', sortOrder: 10, - slots: ['cycle.y'] + slots: ['cycle.y'], )] class LpnFixtureCycleXComponent {} @@ -123,7 +123,7 @@ class LpnFixtureCycleXComponent {} slot: 'cycle.y', handle: 'default', sortOrder: 10, - slots: ['cycle.x'] + slots: ['cycle.x'], )] class LpnFixtureCycleYComponent {} @@ -141,8 +141,7 @@ public function __construct(private readonly ComponentCollection $stub) {} public function collect( array $classNames, string $handle, - ): ComponentCollection - { + ): ComponentCollection { return $this->stub; } @@ -162,16 +161,14 @@ public function __construct(private readonly mixed $renderFn) {} public function render( string $template, array $data = [], - ): Response - { + ): Response { return Response::html(($this->renderFn)($template, $data)); } public function renderToString( string $template, array $data = [], - ): string - { + ): string { return ($this->renderFn)($template, $data); } }; diff --git a/packages/layout/tests/Unit/LayoutProcessorTest.php b/packages/layout/tests/Unit/LayoutProcessorTest.php index c9b65467..f33741cf 100644 --- a/packages/layout/tests/Unit/LayoutProcessorTest.php +++ b/packages/layout/tests/Unit/LayoutProcessorTest.php @@ -96,8 +96,7 @@ public function __construct(private readonly ComponentCollection $stub) {} public function collect( array $classNames, string $handle, - ): ComponentCollection - { + ): ComponentCollection { return $this->stub; } @@ -118,16 +117,14 @@ public function __construct(private readonly mixed $renderFn) {} public function render( string $template, array $data = [], - ): Response - { + ): Response { return Response::html(($this->renderFn)($template, $data)); } public function renderToString( string $template, array $data = [], - ): string - { + ): string { return ($this->renderFn)($template, $data); } }; diff --git a/packages/layout/tests/Unit/Middleware/LayoutMiddlewareTest.php b/packages/layout/tests/Unit/Middleware/LayoutMiddlewareTest.php index 4b9548db..d9b9b6b2 100644 --- a/packages/layout/tests/Unit/Middleware/LayoutMiddlewareTest.php +++ b/packages/layout/tests/Unit/Middleware/LayoutMiddlewareTest.php @@ -72,8 +72,7 @@ public function __construct(private readonly ?MatchedRoute $stub) {} public function match( string $method, string $path, - ): ?MatchedRoute - { + ): ?MatchedRoute { return $this->stub; } }; diff --git a/packages/lsp/src/Features/AttributeFeature.php b/packages/lsp/src/Features/AttributeFeature.php index 86faf1d3..022326f9 100644 --- a/packages/lsp/src/Features/AttributeFeature.php +++ b/packages/lsp/src/Features/AttributeFeature.php @@ -17,8 +17,10 @@ public function __construct(private IndexCache $index) {} * * @return ?array{attribute: string, parameter: string, partial: string} */ - public function detectContext(string $lineText, int $col): ?array - { + public function detectContext( + string $lineText, + int $col, + ): ?array { $prefix = substr($lineText, 0, $col); // Match: #[AttributeName(paramName: 'partial' or paramName: ClassName @@ -41,8 +43,10 @@ public function detectContext(string $lineText, int $col): ?array * * @return list */ - public function complete(string $lineText, int $col): array - { + public function complete( + string $lineText, + int $col, + ): array { $ctx = $this->detectContext($lineText, $col); if ($ctx === null) { @@ -52,7 +56,9 @@ public function complete(string $lineText, int $col): array return match ([$ctx['attribute'], $ctx['parameter']]) { ['Observer', 'event'] => $this->completeEventClasses($ctx['partial']), ['Plugin', 'target'] => $this->completeAllClasses($ctx['partial']), - ['Get', ''], ['Post', ''], ['Put', ''], ['Patch', ''], ['Delete', ''] => $this->completeRoutePaths($ctx['partial']), + ['Get', ''], ['Post', ''], ['Put', ''], ['Patch', ''], ['Delete', ''] => $this->completeRoutePaths( + $ctx['partial'], + ), ['Middleware', ''] => $this->completeMiddlewareClasses($ctx['partial']), default => [], }; diff --git a/packages/lsp/src/Features/ConfigKeyFeature.php b/packages/lsp/src/Features/ConfigKeyFeature.php index 453d4005..83124341 100644 --- a/packages/lsp/src/Features/ConfigKeyFeature.php +++ b/packages/lsp/src/Features/ConfigKeyFeature.php @@ -20,8 +20,7 @@ public function __construct(private IndexCache $index) {} public function detectContext( string $lineText, int $col, - ): ?string - { + ): ?string { $prefix = substr($lineText, 0, $col); $pattern = '/->\s*(' . implode('|', self::CONFIG_GETTER_METHODS) . ')\s*\(\s*[\'"]([^\'"]*)$/'; @@ -146,8 +145,7 @@ public function diagnostics(string $documentText): array public function suggestSimilar( string $key, int $max = 3, - ): array - { + ): array { $candidates = array_map(fn (ConfigKeyEntry $e) => $e->key, $this->index->getConfigKeys()); $scored = array_map(fn (string $c) => ['key' => $c, 'distance' => levenshtein($key, $c)], $candidates); usort($scored, fn (array $a, array $b) => $a['distance'] <=> $b['distance']); diff --git a/packages/lsp/src/Features/TemplateFeature.php b/packages/lsp/src/Features/TemplateFeature.php index 99b0ccba..910e5920 100644 --- a/packages/lsp/src/Features/TemplateFeature.php +++ b/packages/lsp/src/Features/TemplateFeature.php @@ -20,7 +20,7 @@ public function complete(string $partial): array if ($partial !== '' && !str_starts_with($fullName, $partial) && !str_starts_with( $t->templateName, - $partial + $partial, )) { continue; } @@ -135,8 +135,7 @@ public function diagnostics(string $documentText): array public function suggestSimilar( string $template, int $max = 3, - ): array - { + ): array { $candidates = []; foreach ($this->index->getTemplates() as $t) { diff --git a/packages/lsp/src/Features/TranslationFeature.php b/packages/lsp/src/Features/TranslationFeature.php index 8f0a099e..384877f2 100644 --- a/packages/lsp/src/Features/TranslationFeature.php +++ b/packages/lsp/src/Features/TranslationFeature.php @@ -22,8 +22,7 @@ public function __construct( public function detectContext( string $lineText, int $col, - ): ?string - { + ): ?string { $prefix = substr($lineText, 0, $col); $pattern = '/->\s*(' . implode('|', self::TRANSLATOR_METHODS) . ')\s*\(\s*[\'"]([^\'"]*)$/'; @@ -178,8 +177,7 @@ public function diagnostics(string $documentText): array public function suggestSimilar( string $key, int $max = 3, - ): array - { + ): array { $candidates = []; foreach ($this->index->getTranslationKeys() as $entry) { diff --git a/packages/lsp/src/Server/DiagnosticsNotifier.php b/packages/lsp/src/Server/DiagnosticsNotifier.php index 85cd9104..ac073ca5 100644 --- a/packages/lsp/src/Server/DiagnosticsNotifier.php +++ b/packages/lsp/src/Server/DiagnosticsNotifier.php @@ -19,8 +19,10 @@ public function __construct(private LspProtocol $protocol) {} * * @param list> $diagnostics */ - public function publish(string $uri, array $diagnostics): void - { + public function publish( + string $uri, + array $diagnostics, + ): void { $stamped = array_map( fn (array $diag) => array_merge($diag, ['source' => self::SOURCE]), $diagnostics, diff --git a/packages/lsp/src/Server/DocumentStore.php b/packages/lsp/src/Server/DocumentStore.php index 8c53b0f1..0a38506a 100644 --- a/packages/lsp/src/Server/DocumentStore.php +++ b/packages/lsp/src/Server/DocumentStore.php @@ -12,16 +12,14 @@ class DocumentStore public function open( string $uri, string $text, - ): void - { + ): void { $this->documents[$uri] = $text; } public function update( string $uri, string $text, - ): void - { + ): void { $this->documents[$uri] = $text; } diff --git a/packages/lsp/src/Server/LspServer.php b/packages/lsp/src/Server/LspServer.php index a7c55ffd..2178be46 100644 --- a/packages/lsp/src/Server/LspServer.php +++ b/packages/lsp/src/Server/LspServer.php @@ -266,8 +266,10 @@ private function codeLensRequest(array $params): array * Aggregate diagnostics from all features and publish a single * textDocument/publishDiagnostics notification for the given URI. */ - private function publishDiagnosticsFor(string $uri, string $text): void - { + private function publishDiagnosticsFor( + string $uri, + string $text, + ): void { if ($this->notifier === null) { return; } diff --git a/packages/lsp/tests/Unit/Features/TemplateFeatureTest.php b/packages/lsp/tests/Unit/Features/TemplateFeatureTest.php index 1377e51f..217b60b1 100644 --- a/packages/lsp/tests/Unit/Features/TemplateFeatureTest.php +++ b/packages/lsp/tests/Unit/Features/TemplateFeatureTest.php @@ -25,6 +25,7 @@ public function getTemplates(): array function makeTemplateEntry(string $moduleName, string $templateName, string $absolutePath = '', string $extension = 'php'): TemplateEntry { $absolutePath = $absolutePath ?: "/modules/$moduleName/resources/views/$templateName.$extension"; + return new TemplateEntry($moduleName, $templateName, $absolutePath, $extension); } diff --git a/packages/lsp/tests/Unit/Server/LspServerTest.php b/packages/lsp/tests/Unit/Server/LspServerTest.php index 6e3c6b5a..9cfd1ebd 100644 --- a/packages/lsp/tests/Unit/Server/LspServerTest.php +++ b/packages/lsp/tests/Unit/Server/LspServerTest.php @@ -2,12 +2,13 @@ declare(strict_types=1); -use Marko\CodeIndexer\Cache\IndexCache; use Marko\CodeIndexer\Attributes\AttributeParser; +use Marko\CodeIndexer\Cache\IndexCache; use Marko\CodeIndexer\Config\ConfigScanner; use Marko\CodeIndexer\Module\ModuleWalker; -use Marko\CodeIndexer\Views\TemplateScanner; use Marko\CodeIndexer\Translations\TranslationScanner; +use Marko\CodeIndexer\ValueObject\ModuleInfo; +use Marko\CodeIndexer\Views\TemplateScanner; use Marko\Core\Path\ProjectPaths; use Marko\Lsp\Features\AttributeFeature; use Marko\Lsp\Features\CodeLensFeature; @@ -20,7 +21,8 @@ function makeNullIndexCache(): IndexCache { - $emptyWalker = new class () extends ModuleWalker { + $emptyWalker = new class () extends ModuleWalker + { public function __construct() {} public function walk(): array @@ -28,46 +30,50 @@ public function walk(): array return []; } }; - $emptyAttributeParser = new class () extends AttributeParser { - public function observers(\Marko\CodeIndexer\ValueObject\ModuleInfo $m): array + $emptyAttributeParser = new class () extends AttributeParser + { + public function observers(ModuleInfo $m): array { return []; } - public function plugins(\Marko\CodeIndexer\ValueObject\ModuleInfo $m): array + public function plugins(ModuleInfo $m): array { return []; } - public function preferences(\Marko\CodeIndexer\ValueObject\ModuleInfo $m): array + public function preferences(ModuleInfo $m): array { return []; } - public function commands(\Marko\CodeIndexer\ValueObject\ModuleInfo $m): array + public function commands(ModuleInfo $m): array { return []; } - public function routes(\Marko\CodeIndexer\ValueObject\ModuleInfo $m): array + public function routes(ModuleInfo $m): array { return []; } }; - $emptyConfigScanner = new class () extends ConfigScanner { - public function scan(\Marko\CodeIndexer\ValueObject\ModuleInfo $m): array + $emptyConfigScanner = new class () extends ConfigScanner + { + public function scan(ModuleInfo $m): array { return []; } }; - $emptyTemplateScanner = new class () extends TemplateScanner { - public function scan(\Marko\CodeIndexer\ValueObject\ModuleInfo $m): array + $emptyTemplateScanner = new class () extends TemplateScanner + { + public function scan(ModuleInfo $m): array { return []; } }; - $emptyTranslationScanner = new class () extends TranslationScanner { - public function scan(\Marko\CodeIndexer\ValueObject\ModuleInfo $m): array + $emptyTranslationScanner = new class () extends TranslationScanner + { + public function scan(ModuleInfo $m): array { return []; } @@ -110,7 +116,7 @@ function readResponse($out): array it('responds to initialize with server capabilities', function () { $this->protocol->handleMessage( - json_encode(['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => [], 'id' => 1]) + json_encode(['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => [], 'id' => 1]), ); $response = readResponse($this->out); expect($response['result']['capabilities'])->toBeArray() diff --git a/packages/lsp/tests/Unit/Server/PublishDiagnosticsTest.php b/packages/lsp/tests/Unit/Server/PublishDiagnosticsTest.php index c5dd06ba..bf5c08b8 100644 --- a/packages/lsp/tests/Unit/Server/PublishDiagnosticsTest.php +++ b/packages/lsp/tests/Unit/Server/PublishDiagnosticsTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -use Marko\CodeIndexer\Cache\IndexCache; use Marko\CodeIndexer\Attributes\AttributeParser; +use Marko\CodeIndexer\Cache\IndexCache; use Marko\CodeIndexer\Config\ConfigScanner; use Marko\CodeIndexer\Module\ModuleWalker; -use Marko\CodeIndexer\Views\TemplateScanner; use Marko\CodeIndexer\Translations\TranslationScanner; use Marko\CodeIndexer\ValueObject\ConfigKeyEntry; use Marko\CodeIndexer\ValueObject\ModuleInfo; +use Marko\CodeIndexer\Views\TemplateScanner; use Marko\Core\Path\ProjectPaths; use Marko\Lsp\Features\AttributeFeature; use Marko\Lsp\Features\CodeLensFeature; diff --git a/packages/mail/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/mail/tests/Unit/Exceptions/NoDriverExceptionTest.php index 23eff5aa..96f898e1 100644 --- a/packages/mail/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/mail/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -61,7 +61,7 @@ $exception = NoDriverException::noDriverInstalled(); expect($exception->getContext())->toBe( - 'Attempted to resolve a mail interface but no implementation is bound.' + 'Attempted to resolve a mail interface but no implementation is bound.', ); }); diff --git a/packages/mcp/src/Commands/ServeCommand.php b/packages/mcp/src/Commands/ServeCommand.php index f30987dd..a76520f2 100644 --- a/packages/mcp/src/Commands/ServeCommand.php +++ b/packages/mcp/src/Commands/ServeCommand.php @@ -24,8 +24,7 @@ public function __construct( public function execute( Input $input, Output $output, - ): int - { + ): int { fwrite(STDERR, "Marko MCP server starting on stdio...\n"); $this->server->serve(); fwrite(STDERR, "Marko MCP server shut down.\n"); diff --git a/packages/mcp/src/Protocol/JsonRpcProtocol.php b/packages/mcp/src/Protocol/JsonRpcProtocol.php index 5084d48a..2268de15 100644 --- a/packages/mcp/src/Protocol/JsonRpcProtocol.php +++ b/packages/mcp/src/Protocol/JsonRpcProtocol.php @@ -24,8 +24,7 @@ public function __construct( public function registerMethod( string $method, callable $handler, - ): void - { + ): void { $this->handlers[$method] = $handler; } @@ -61,17 +60,17 @@ public function handleMessage(string $jsonLine): void $request = json_decode($jsonLine, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { $this->writeResponse( - ['jsonrpc' => '2.0', 'error' => ['code' => -32700, 'message' => 'Parse error: ' . $e->getMessage()], 'id' => null] + ['jsonrpc' => '2.0', 'error' => ['code' => -32700, 'message' => 'Parse error: ' . $e->getMessage()], 'id' => null], ); return; } if (!is_array( - $request + $request, ) || !isset($request['jsonrpc']) || $request['jsonrpc'] !== '2.0' || !isset($request['method'])) { $this->writeResponse( - ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'Invalid Request'], 'id' => $request['id'] ?? null] + ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'Invalid Request'], 'id' => $request['id'] ?? null], ); return; @@ -87,7 +86,7 @@ public function handleMessage(string $jsonLine): void return; } $this->writeResponse( - ['jsonrpc' => '2.0', 'error' => ['code' => -32601, 'message' => "Method not found: $method"], 'id' => $id] + ['jsonrpc' => '2.0', 'error' => ['code' => -32601, 'message' => "Method not found: $method"], 'id' => $id], ); return; @@ -104,14 +103,14 @@ public function handleMessage(string $jsonLine): void return; } $this->writeResponse( - ['jsonrpc' => '2.0', 'error' => ['code' => $e->getJsonRpcCode(), 'message' => $e->getMessage()], 'id' => $id] + ['jsonrpc' => '2.0', 'error' => ['code' => $e->getJsonRpcCode(), 'message' => $e->getMessage()], 'id' => $id], ); } catch (Throwable $e) { if ($isNotification) { return; } $this->writeResponse( - ['jsonrpc' => '2.0', 'error' => ['code' => -32603, 'message' => 'Internal error: ' . $e->getMessage()], 'id' => $id] + ['jsonrpc' => '2.0', 'error' => ['code' => -32603, 'message' => 'Internal error: ' . $e->getMessage()], 'id' => $id], ); } } diff --git a/packages/mcp/src/Server/McpServer.php b/packages/mcp/src/Server/McpServer.php index ad12dbb4..04f35dde 100644 --- a/packages/mcp/src/Server/McpServer.php +++ b/packages/mcp/src/Server/McpServer.php @@ -147,8 +147,7 @@ private function toolsCall(array $params): array private function validateArgs( array $args, array $schema, - ): void - { + ): void { $required = $schema['required'] ?? []; foreach ($required as $field) { if (!array_key_exists($field, $args)) { diff --git a/packages/mcp/src/Tools/CheckConfigKeyTool.php b/packages/mcp/src/Tools/CheckConfigKeyTool.php index 5f9c8864..cbba69bb 100644 --- a/packages/mcp/src/Tools/CheckConfigKeyTool.php +++ b/packages/mcp/src/Tools/CheckConfigKeyTool.php @@ -67,8 +67,7 @@ private function closestMatches( string $needle, array $candidates, int $max, - ): array - { + ): array { $scored = array_map(fn ($c) => ['key' => $c, 'distance' => levenshtein($needle, $c)], $candidates); usort($scored, fn ($a, $b) => $a['distance'] <=> $b['distance']); diff --git a/packages/mcp/src/Tools/ResolveTemplateTool.php b/packages/mcp/src/Tools/ResolveTemplateTool.php index ae16bc4d..38476930 100644 --- a/packages/mcp/src/Tools/ResolveTemplateTool.php +++ b/packages/mcp/src/Tools/ResolveTemplateTool.php @@ -50,7 +50,7 @@ public function handle(array $arguments): array return [ 'content' => [['type' => 'text', 'text' => "Template not found: $template\nAvailable in module '$module': " . implode( ', ', - $names + $names, )]], 'isError' => true, ]; diff --git a/packages/mcp/src/Tools/Runtime/Adapters/MarkoConsoleDispatcher.php b/packages/mcp/src/Tools/Runtime/Adapters/MarkoConsoleDispatcher.php index 8b8186f4..11de1df3 100644 --- a/packages/mcp/src/Tools/Runtime/Adapters/MarkoConsoleDispatcher.php +++ b/packages/mcp/src/Tools/Runtime/Adapters/MarkoConsoleDispatcher.php @@ -27,8 +27,7 @@ public function __construct( public function dispatch( string $command, array $args = [], - ): array - { + ): array { $stdout = fopen('php://memory', 'r+'); $stderr = fopen('php://memory', 'r+'); diff --git a/packages/mcp/src/Tools/Runtime/Adapters/MarkoQueryConnection.php b/packages/mcp/src/Tools/Runtime/Adapters/MarkoQueryConnection.php index 46f03e5c..bc4813ed 100644 --- a/packages/mcp/src/Tools/Runtime/Adapters/MarkoQueryConnection.php +++ b/packages/mcp/src/Tools/Runtime/Adapters/MarkoQueryConnection.php @@ -23,8 +23,7 @@ public function __construct( public function query( string $sql, array $params = [], - ): array - { + ): array { return array_values($this->connection->query($sql, $params)); } } diff --git a/packages/mcp/tests/Unit/Server/McpServerTest.php b/packages/mcp/tests/Unit/Server/McpServerTest.php index 731aa669..5aa382bb 100644 --- a/packages/mcp/tests/Unit/Server/McpServerTest.php +++ b/packages/mcp/tests/Unit/Server/McpServerTest.php @@ -16,7 +16,7 @@ it('responds to initialize with protocol version and capabilities', function (): void { $this->protocol->handleMessage( - json_encode(['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => [], 'id' => 1]) + json_encode(['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => [], 'id' => 1]), ); rewind($this->out); $response = json_decode((string) stream_get_contents($this->out), true); @@ -31,7 +31,7 @@ // namespace `mcp__marko/mcp__` is invalid. Tool identifiers must // not contain slashes — keep the self-reported name dash-separated. $this->protocol->handleMessage( - json_encode(['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => [], 'id' => 1]) + json_encode(['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => [], 'id' => 1]), ); rewind($this->out); $response = json_decode((string) stream_get_contents($this->out), true); @@ -55,7 +55,7 @@ public function handle(array $arguments): array )); $this->protocol->handleMessage( - json_encode(['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => [], 'id' => 1]) + json_encode(['jsonrpc' => '2.0', 'method' => 'initialize', 'params' => [], 'id' => 1]), ); rewind($this->out); $response = json_decode((string) stream_get_contents($this->out), true); @@ -130,8 +130,8 @@ public function handle(array $arguments): array $this->protocol->handleMessage( json_encode( - ['jsonrpc' => '2.0', 'method' => 'tools/call', 'params' => ['name' => 'greet', 'arguments' => ['name' => 'world']], 'id' => 3] - ) + ['jsonrpc' => '2.0', 'method' => 'tools/call', 'params' => ['name' => 'greet', 'arguments' => ['name' => 'world']], 'id' => 3], + ), ); rewind($this->out); $response = json_decode((string) stream_get_contents($this->out), true); @@ -142,8 +142,8 @@ public function handle(array $arguments): array it('returns JSON-RPC error for unknown tool names', function (): void { $this->protocol->handleMessage( json_encode( - ['jsonrpc' => '2.0', 'method' => 'tools/call', 'params' => ['name' => 'missing', 'arguments' => []], 'id' => 4] - ) + ['jsonrpc' => '2.0', 'method' => 'tools/call', 'params' => ['name' => 'missing', 'arguments' => []], 'id' => 4], + ), ); rewind($this->out); $response = json_decode((string) stream_get_contents($this->out), true); @@ -169,8 +169,8 @@ public function handle(array $arguments): array // Missing required field 'q' $this->protocol->handleMessage( json_encode( - ['jsonrpc' => '2.0', 'method' => 'tools/call', 'params' => ['name' => 'search', 'arguments' => []], 'id' => 5] - ) + ['jsonrpc' => '2.0', 'method' => 'tools/call', 'params' => ['name' => 'search', 'arguments' => []], 'id' => 5], + ), ); rewind($this->out); $response = json_decode((string) stream_get_contents($this->out), true); @@ -196,8 +196,8 @@ public function handle(array $arguments): array $this->protocol->handleMessage( json_encode( - ['jsonrpc' => '2.0', 'method' => 'tools/call', 'params' => ['name' => 'fetch', 'arguments' => []], 'id' => 6] - ) + ['jsonrpc' => '2.0', 'method' => 'tools/call', 'params' => ['name' => 'fetch', 'arguments' => []], 'id' => 6], + ), ); rewind($this->out); $response = json_decode((string) stream_get_contents($this->out), true); diff --git a/packages/mcp/tests/Unit/Tools/CheckConfigKeyToolTest.php b/packages/mcp/tests/Unit/Tools/CheckConfigKeyToolTest.php index ec843a08..9f108063 100644 --- a/packages/mcp/tests/Unit/Tools/CheckConfigKeyToolTest.php +++ b/packages/mcp/tests/Unit/Tools/CheckConfigKeyToolTest.php @@ -37,7 +37,7 @@ public function getConfigKeys(): array defaultValue: 'file', module: 'Marko_Cache', file: 'config/cache.php', - line: 5 + line: 5, ), ]; $index = makeFakeIndexCacheForCheck($entries); @@ -62,7 +62,7 @@ public function getConfigKeys(): array defaultValue: 'file', module: 'Marko_Cache', file: 'config/cache.php', - line: 5 + line: 5, ), new ConfigKeyEntry( key: 'cache.store', @@ -70,7 +70,7 @@ public function getConfigKeys(): array defaultValue: 'default', module: 'Marko_Cache', file: 'config/cache.php', - line: 10 + line: 10, ), ]; $index = makeFakeIndexCacheForCheck($entries); @@ -94,7 +94,7 @@ public function getConfigKeys(): array defaultValue: 'localhost', module: 'Marko_Mail', file: 'config/mail.php', - line: 12 + line: 12, ), ]; $index = makeFakeIndexCacheForCheck($entries); diff --git a/packages/mcp/tests/Unit/Tools/FindPluginsTargetingToolTest.php b/packages/mcp/tests/Unit/Tools/FindPluginsTargetingToolTest.php index d3d11b21..cdba340f 100644 --- a/packages/mcp/tests/Unit/Tools/FindPluginsTargetingToolTest.php +++ b/packages/mcp/tests/Unit/Tools/FindPluginsTargetingToolTest.php @@ -55,14 +55,14 @@ class: 'App\Plugins\OrderPlugin', ); $index = makeFakeIndexCacheForPlugins([$plugin]); $tool = FindPluginsTargetingTool::definition($index); - + $result = $tool->handler->handle(['target' => 'App\Services\OrderService']); - + expect($result['content'][0]['text'])->toContain('App\Plugins\OrderPlugin'); expect($result['content'][0]['text'])->toContain('beforePlace'); expect($result['content'][0]['text'])->toContain('before'); expect($result['content'][0]['text'])->toContain('10'); - } + }, ); it('returns empty list for classes with no plugins', function (): void { diff --git a/packages/mcp/tests/Unit/Tools/GetConfigSchemaToolTest.php b/packages/mcp/tests/Unit/Tools/GetConfigSchemaToolTest.php index bb1a2fd8..36a70852 100644 --- a/packages/mcp/tests/Unit/Tools/GetConfigSchemaToolTest.php +++ b/packages/mcp/tests/Unit/Tools/GetConfigSchemaToolTest.php @@ -30,7 +30,7 @@ public function getConfigKeys(): array defaultValue: 'file', module: 'Marko_Cache', file: 'config/cache.php', - line: 5 + line: 5, ), new ConfigKeyEntry( key: 'mail.host', @@ -38,7 +38,7 @@ public function getConfigKeys(): array defaultValue: 'localhost', module: 'Marko_Mail', file: 'config/mail.php', - line: 3 + line: 3, ), ]; $index = makeFakeIndexCache($entries); diff --git a/packages/mcp/tests/Unit/Tools/ListToolsTest.php b/packages/mcp/tests/Unit/Tools/ListToolsTest.php index 900975ad..456cf8d8 100644 --- a/packages/mcp/tests/Unit/Tools/ListToolsTest.php +++ b/packages/mcp/tests/Unit/Tools/ListToolsTest.php @@ -71,12 +71,12 @@ public function getRoutes(): array new CommandEntry( name: 'cache:clear', class: 'Marko\\Cache\\Command\\ClearCommand', - description: 'Clears cache' + description: 'Clears cache', ), new CommandEntry( name: 'module:list', class: 'Marko\\Core\\Command\\ModuleListCommand', - description: 'Lists modules' + description: 'Lists modules', ), ]); @@ -103,13 +103,13 @@ class: 'Marko\\Core\\Command\\ModuleListCommand', method: 'GET', path: '/api/users', class: 'Marko\\Api\\Controller\\UserController', - action: 'index' + action: 'index', ), new RouteEntry( method: 'POST', path: '/api/users', class: 'Marko\\Api\\Controller\\UserController', - action: 'store' + action: 'store', ), ]); @@ -142,12 +142,12 @@ class: 'Marko\\Api\\Controller\\UserController', new CommandEntry( name: 'cache:clear', class: 'Marko\\Cache\\Command\\ClearCommand', - description: 'Clears cache' + description: 'Clears cache', ), new CommandEntry( name: 'module:list', class: 'Marko\\Core\\Command\\ModuleListCommand', - description: 'Lists modules' + description: 'Lists modules', ), ], routes: [ @@ -155,13 +155,13 @@ class: 'Marko\\Core\\Command\\ModuleListCommand', method: 'GET', path: '/api/users', class: 'Marko\\Api\\Controller\\UserController', - action: 'index' + action: 'index', ), new RouteEntry( method: 'POST', path: '/api/orders', class: 'Marko\\Api\\Controller\\OrderController', - action: 'store' + action: 'store', ), ], ); @@ -202,19 +202,19 @@ class: 'Marko\\Api\\Controller\\OrderController', method: 'GET', path: '/api/users', class: 'Marko\\Api\\Controller\\UserController', - action: 'index' + action: 'index', ), ], ); $moduleText = ListModulesTool::definition($cache)->handler->handle( - ['filter' => 'nonexistent'] + ['filter' => 'nonexistent'], )['content'][0]['text']; $cmdText = ListCommandsTool::definition($cache)->handler->handle( - ['filter' => 'nonexistent'] + ['filter' => 'nonexistent'], )['content'][0]['text']; $routeText = ListRoutesTool::definition($cache)->handler->handle( - ['filter' => 'nonexistent'] + ['filter' => 'nonexistent'], )['content'][0]['text']; expect($moduleText)->toBe('(no modules found)') @@ -239,7 +239,7 @@ class: 'Marko\\Api\\Controller\\UserController', method: 'GET', path: '/api/users', class: 'Marko\\Api\\Controller\\UserController', - action: 'index' + action: 'index', ), ], ); diff --git a/packages/mcp/tests/Unit/Tools/ResolveTemplateToolTest.php b/packages/mcp/tests/Unit/Tools/ResolveTemplateToolTest.php index 760c5547..55c4ddc8 100644 --- a/packages/mcp/tests/Unit/Tools/ResolveTemplateToolTest.php +++ b/packages/mcp/tests/Unit/Tools/ResolveTemplateToolTest.php @@ -59,7 +59,7 @@ public function getTemplates(): array $result = $tool->handler->handle(['template' => 'App\Catalog::product/view']); expect($result['content'][0]['text'])->toContain( - '/var/www/modules/catalog/resources/views/product/view.blade.php' + '/var/www/modules/catalog/resources/views/product/view.blade.php', ); expect($result)->not->toHaveKey('isError'); }); diff --git a/packages/mcp/tests/Unit/Tools/Runtime/ReadLogEntriesToolTest.php b/packages/mcp/tests/Unit/Tools/Runtime/ReadLogEntriesToolTest.php index 4a3f1a9e..6a4d139a 100644 --- a/packages/mcp/tests/Unit/Tools/Runtime/ReadLogEntriesToolTest.php +++ b/packages/mcp/tests/Unit/Tools/Runtime/ReadLogEntriesToolTest.php @@ -26,24 +26,24 @@ function (): void { '[2024-01-01 00:00:02] INFO: Request completed', '[2024-01-01 00:00:03] WARNING: High memory usage', ]); - + $definition = ReadLogEntriesTool::definition($reader); - + expect($definition->name)->toBe('read_log_entries'); - + // Default count - $result = $definition->handler->handle([]); + $result = $definition->handler->handle([]); $text = $result['content'][0]['text']; - + expect($text)->toContain('ERROR: Something failed') ->and($text)->toContain('INFO: Request completed') ->and($text)->toContain('WARNING: High memory usage'); - + // Explicit count - $result2 = $definition->handler->handle(['count' => 1]); + $result2 = $definition->handler->handle(['count' => 1]); $text2 = $result2['content'][0]['text']; - + expect($text2)->toContain('WARNING: High memory usage') ->and($text2)->not->toContain('ERROR: Something failed'); - } + }, ); diff --git a/packages/mcp/tests/Unit/Tools/Runtime/RunConsoleCommandToolTest.php b/packages/mcp/tests/Unit/Tools/Runtime/RunConsoleCommandToolTest.php index d0f73fe5..89a0ddba 100644 --- a/packages/mcp/tests/Unit/Tools/Runtime/RunConsoleCommandToolTest.php +++ b/packages/mcp/tests/Unit/Tools/Runtime/RunConsoleCommandToolTest.php @@ -14,8 +14,7 @@ public function __construct(private readonly array $result) {} public function dispatch( string $command, array $args = [], - ): array - { + ): array { return $this->result; } }; diff --git a/packages/mcp/tests/Unit/Tools/Runtime/ToolFailureModeTest.php b/packages/mcp/tests/Unit/Tools/Runtime/ToolFailureModeTest.php index 5d7c8814..33a7dd27 100644 --- a/packages/mcp/tests/Unit/Tools/Runtime/ToolFailureModeTest.php +++ b/packages/mcp/tests/Unit/Tools/Runtime/ToolFailureModeTest.php @@ -18,14 +18,13 @@ public function __construct() {} public function dispatch( string $command, array $args = [], - ): array - { + ): array { throw new RuntimeException('Command not found: nonexistent'); } }; $consoleResult = RunConsoleCommandTool::definition($throwingDispatcher)->handler->handle( - ['command' => 'nonexistent'] + ['command' => 'nonexistent'], ); expect($consoleResult['content'][0]['text'])->toContain('ERROR') ->and($consoleResult['isError'] ?? false)->toBeTrue(); @@ -38,8 +37,7 @@ public function __construct() {} public function query( string $sql, array $params = [], - ): array - { + ): array { return []; } })->handler->handle(['sql' => 'DROP TABLE users']); diff --git a/packages/mcp/tests/Unit/Tools/SearchDocsToolTest.php b/packages/mcp/tests/Unit/Tools/SearchDocsToolTest.php index 6e2eea0c..ce08e0e0 100644 --- a/packages/mcp/tests/Unit/Tools/SearchDocsToolTest.php +++ b/packages/mcp/tests/Unit/Tools/SearchDocsToolTest.php @@ -73,8 +73,8 @@ public function driverName(): string // Missing required 'query' field $protocol->handleMessage( json_encode( - ['jsonrpc' => '2.0', 'method' => 'tools/call', 'params' => ['name' => 'search_docs', 'arguments' => []], 'id' => 1] - ) + ['jsonrpc' => '2.0', 'method' => 'tools/call', 'params' => ['name' => 'search_docs', 'arguments' => []], 'id' => 1], + ), ); rewind($out); $response = json_decode((string) stream_get_contents($out), true); diff --git a/packages/notification/tests/KnownDriversValidationTest.php b/packages/notification/tests/KnownDriversValidationTest.php index 6a94a268..a37da75f 100644 --- a/packages/notification/tests/KnownDriversValidationTest.php +++ b/packages/notification/tests/KnownDriversValidationTest.php @@ -11,7 +11,7 @@ 'skeleton suggest block contains all notification drivers', function () use ($knownDriversPath, $skeletonComposerPath) { KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); - } + }, ); test('every notification driver follows marko slash prefix pattern', function () use ($knownDriversPath) { diff --git a/packages/page-cache/tests/KnownDriversValidationTest.php b/packages/page-cache/tests/KnownDriversValidationTest.php index 582a7c6a..2c89c0b0 100644 --- a/packages/page-cache/tests/KnownDriversValidationTest.php +++ b/packages/page-cache/tests/KnownDriversValidationTest.php @@ -9,9 +9,9 @@ test( 'skeleton suggest block contains all page-cache drivers', - fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath) + fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath), ); test( 'every page-cache driver follows marko slash prefix pattern', - fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath) + fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath), ); diff --git a/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php index 95bd9a96..5c577991 100644 --- a/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -17,8 +17,8 @@ 'page-cache NoDriverException exposes a noDriverInstalled() factory (renamed from noBinding for consistency)', function (): void { $exception = NoDriverException::noDriverInstalled(); - + expect($exception)->toBeInstanceOf(NoDriverException::class) ->and($exception)->toBeInstanceOf(PageCacheException::class); - } + }, ); diff --git a/packages/pubsub-pgsql/src/Driver/PgSqlPublisher.php b/packages/pubsub-pgsql/src/Driver/PgSqlPublisher.php index d29d9ac0..7e1cfc63 100644 --- a/packages/pubsub-pgsql/src/Driver/PgSqlPublisher.php +++ b/packages/pubsub-pgsql/src/Driver/PgSqlPublisher.php @@ -19,8 +19,7 @@ public function __construct( public function publish( string $channel, Message $message, - ): void - { + ): void { $prefixed = $this->config->prefix() . $channel; $this->connection->connection()->notify($prefixed, $message->payload); } diff --git a/packages/pubsub-pgsql/tests/Driver/PgSqlPublisherTest.php b/packages/pubsub-pgsql/tests/Driver/PgSqlPublisherTest.php index c2d45081..cbf0dba8 100644 --- a/packages/pubsub-pgsql/tests/Driver/PgSqlPublisherTest.php +++ b/packages/pubsub-pgsql/tests/Driver/PgSqlPublisherTest.php @@ -27,8 +27,7 @@ class PublisherStubPostgresConnection implements PostgresConnection public function notify( string $channel, string $payload = '', - ): PostgresResult - { + ): PostgresResult { $this->notifyCalls[] = ['channel' => $channel, 'payload' => $payload]; return new PublisherStubPostgresResult(); @@ -57,8 +56,7 @@ public function prepare(string $sql): PostgresStatement public function execute( string $sql, array $params = [], - ): PostgresResult - { + ): PostgresResult { throw new RuntimeException('Not implemented in stub'); } diff --git a/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriberTest.php b/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriberTest.php index 762d7221..afc3cc56 100644 --- a/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriberTest.php +++ b/packages/pubsub-pgsql/tests/Driver/PgSqlSubscriberTest.php @@ -33,8 +33,7 @@ class MockPostgresListener implements PostgresListener, IteratorAggregate public function __construct( string $channel, array $notifications = [], - ) - { + ) { $this->channel = $channel; $this->notifications = $notifications; } @@ -88,8 +87,7 @@ public function listen(string $channel): PostgresListener public function notify( string $channel, string $payload = '', - ): PostgresResult - { + ): PostgresResult { throw new RuntimeException('Not implemented in stub'); } @@ -111,8 +109,7 @@ public function prepare(string $sql): PostgresStatement public function execute( string $sql, array $params = [], - ): PostgresResult - { + ): PostgresResult { throw new RuntimeException('Not implemented in stub'); } diff --git a/packages/pubsub-pgsql/tests/PgSqlPubSubConnectionTest.php b/packages/pubsub-pgsql/tests/PgSqlPubSubConnectionTest.php index 48a22dbb..1f80f65d 100644 --- a/packages/pubsub-pgsql/tests/PgSqlPubSubConnectionTest.php +++ b/packages/pubsub-pgsql/tests/PgSqlPubSubConnectionTest.php @@ -19,8 +19,7 @@ class ConnectionStubPostgresConnection implements PostgresConnection public function notify( string $channel, string $payload = '', - ): PostgresResult - { + ): PostgresResult { throw new RuntimeException('Not implemented in stub'); } @@ -47,8 +46,7 @@ public function prepare(string $sql): PostgresStatement public function execute( string $sql, array $params = [], - ): PostgresResult - { + ): PostgresResult { throw new RuntimeException('Not implemented in stub'); } diff --git a/packages/pubsub-redis/src/Driver/RedisPublisher.php b/packages/pubsub-redis/src/Driver/RedisPublisher.php index 87fd463b..68f4ff56 100644 --- a/packages/pubsub-redis/src/Driver/RedisPublisher.php +++ b/packages/pubsub-redis/src/Driver/RedisPublisher.php @@ -19,8 +19,7 @@ public function __construct( public function publish( string $channel, Message $message, - ): void - { + ): void { $prefixed = $this->config->prefix() . $channel; $this->connection->client()->publish($prefixed, $message->payload); } diff --git a/packages/pubsub-redis/tests/Driver/RedisPublisherTest.php b/packages/pubsub-redis/tests/Driver/RedisPublisherTest.php index b49370d8..4ffa4852 100644 --- a/packages/pubsub-redis/tests/Driver/RedisPublisherTest.php +++ b/packages/pubsub-redis/tests/Driver/RedisPublisherTest.php @@ -32,8 +32,7 @@ class PublisherTestStubRedisLink implements RedisLink public function execute( string $command, array $parameters, - ): RedisResponse - { + ): RedisResponse { $this->calls[] = ['command' => $command, 'parameters' => $parameters]; return new PublisherTestStubRedisResponse(); diff --git a/packages/pubsub-redis/tests/RedisPubSubConnectionTest.php b/packages/pubsub-redis/tests/RedisPubSubConnectionTest.php index 9bfafee7..9e824ef5 100644 --- a/packages/pubsub-redis/tests/RedisPubSubConnectionTest.php +++ b/packages/pubsub-redis/tests/RedisPubSubConnectionTest.php @@ -30,8 +30,7 @@ class StubRedisLink implements RedisLink public function execute( string $command, array $parameters, - ): RedisResponse - { + ): RedisResponse { $this->calls[] = ['command' => $command, 'parameters' => $parameters]; return new StubRedisResponse(); diff --git a/packages/pubsub/src/Exceptions/PubSubException.php b/packages/pubsub/src/Exceptions/PubSubException.php index c4cbde42..799ddc95 100644 --- a/packages/pubsub/src/Exceptions/PubSubException.php +++ b/packages/pubsub/src/Exceptions/PubSubException.php @@ -11,8 +11,7 @@ class PubSubException extends MarkoException public static function connectionFailed( string $driver, string $reason, - ): self - { + ): self { return new self( message: "Failed to connect to pub/sub driver '$driver'.", context: "Connection attempt to driver '$driver' failed: $reason", @@ -23,8 +22,7 @@ public static function connectionFailed( public static function subscriptionFailed( string $channel, string $reason, - ): self - { + ): self { return new self( message: "Failed to subscribe to channel '$channel'.", context: "Subscription to channel '$channel' failed: $reason", @@ -35,8 +33,7 @@ public static function subscriptionFailed( public static function publishFailed( string $channel, string $reason, - ): self - { + ): self { return new self( message: "Failed to publish to channel '$channel'.", context: "Publish to channel '$channel' failed: $reason", diff --git a/packages/pubsub/tests/KnownDriversValidationTest.php b/packages/pubsub/tests/KnownDriversValidationTest.php index 0713ada2..61000501 100644 --- a/packages/pubsub/tests/KnownDriversValidationTest.php +++ b/packages/pubsub/tests/KnownDriversValidationTest.php @@ -9,9 +9,9 @@ test( 'skeleton suggest block contains all pubsub drivers', - fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath) + fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath), ); test( 'every pubsub driver follows marko slash prefix pattern', - fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath) + fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath), ); diff --git a/packages/pubsub/tests/MessageTest.php b/packages/pubsub/tests/MessageTest.php index a501cf0c..07de8002 100644 --- a/packages/pubsub/tests/MessageTest.php +++ b/packages/pubsub/tests/MessageTest.php @@ -9,13 +9,13 @@ 'creates readonly Message value object with channel, payload, and optional pattern properties', function (): void { $reflection = new ReflectionClass(Message::class); - + expect($reflection->isReadOnly())->toBeTrue(); - + $channelProp = $reflection->getProperty('channel'); $payloadProp = $reflection->getProperty('payload'); $patternProp = $reflection->getProperty('pattern'); - + expect($channelProp->isPublic())->toBeTrue() ->and($channelProp->getType()?->getName())->toBe('string') ->and($payloadProp->isPublic())->toBeTrue() @@ -23,7 +23,7 @@ function (): void { ->and($patternProp->isPublic())->toBeTrue() ->and($patternProp->getType()?->allowsNull())->toBeTrue() ->and($patternProp->getType()?->getName())->toBe('string'); - } + }, ); it('creates Message with all properties accessible', function (): void { diff --git a/packages/pubsub/tests/SubscriberInterfaceTest.php b/packages/pubsub/tests/SubscriberInterfaceTest.php index c4c4cf3a..c2a7b822 100644 --- a/packages/pubsub/tests/SubscriberInterfaceTest.php +++ b/packages/pubsub/tests/SubscriberInterfaceTest.php @@ -10,43 +10,43 @@ 'defines SubscriberInterface with subscribe method accepting variadic channels returning Subscription', function (): void { $reflection = new ReflectionClass(SubscriberInterface::class); - + expect($reflection->isInterface())->toBeTrue() ->and($reflection->hasMethod('subscribe'))->toBeTrue(); - + $method = $reflection->getMethod('subscribe'); - + expect($method->isPublic())->toBeTrue() ->and($method->getReturnType()?->getName())->toBe(Subscription::class) ->and($method->getParameters())->toHaveCount(1); - + $channelsParam = $method->getParameters()[0]; - + expect($channelsParam->getName())->toBe('channels') ->and($channelsParam->getType()?->getName())->toBe('string') ->and($channelsParam->isVariadic())->toBeTrue(); - } + }, ); it( 'defines SubscriberInterface with psubscribe method accepting variadic patterns returning Subscription', function (): void { $reflection = new ReflectionClass(SubscriberInterface::class); - + expect($reflection->isInterface())->toBeTrue() ->and($reflection->hasMethod('psubscribe'))->toBeTrue(); - + $method = $reflection->getMethod('psubscribe'); - + expect($method->isPublic())->toBeTrue() ->and($method->getReturnType()?->getName())->toBe(Subscription::class) ->and($method->getParameters())->toHaveCount(1); - + $patternsParam = $method->getParameters()[0]; - + expect($patternsParam->getName())->toBe('patterns') ->and($patternsParam->getType()?->getName())->toBe('string') ->and($patternsParam->isVariadic())->toBeTrue(); - } + }, ); }); diff --git a/packages/queue/tests/KnownDriversValidationTest.php b/packages/queue/tests/KnownDriversValidationTest.php index 1173c88e..b5bec8c2 100644 --- a/packages/queue/tests/KnownDriversValidationTest.php +++ b/packages/queue/tests/KnownDriversValidationTest.php @@ -23,17 +23,17 @@ 'it lists marko/queue-sync first as the recommended development default', function () use ($knownDriversPath): void { $drivers = require $knownDriversPath; - + expect(array_key_first($drivers))->toBe('marko/queue-sync'); - } + }, ); test( 'skeleton suggest block contains all queue drivers', - fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath) + fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath), ); test( 'every queue driver follows marko slash prefix pattern', - fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath) + fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath), ); diff --git a/packages/ratelimiter/tests/Unit/RenameReferencesTest.php b/packages/ratelimiter/tests/Unit/RenameReferencesTest.php index 5bdaa64c..9a91a11c 100644 --- a/packages/ratelimiter/tests/Unit/RenameReferencesTest.php +++ b/packages/ratelimiter/tests/Unit/RenameReferencesTest.php @@ -7,26 +7,26 @@ function (): void { $monorepoRoot = dirname(__DIR__, 4); $ratelimiterPackage = dirname(__DIR__, 2); - + $composerFiles = glob($monorepoRoot . '/packages/*/composer.json') ?: []; $composerFiles[] = $monorepoRoot . '/composer.json'; - + $hits = []; - + foreach ($composerFiles as $file) { // Skip files inside the ratelimiter package itself - if (str_starts_with(realpath($file), realpath($ratelimiterPackage))) { + if (str_starts_with(realpath($file), realpath($ratelimiterPackage))) { continue; } - + $contents = file_get_contents($file); if (str_contains($contents, 'marko/rate-limiting')) { $hits[] = $file; } } - + expect($hits)->toBe([], 'Found marko/rate-limiting references in: ' . implode(', ', $hits)); - } + }, ); it('has zero grep hits for Marko\\RateLimiting namespace outside the ratelimiter package', function (): void { @@ -81,17 +81,17 @@ function (): void { it('runs composer dump-autoload without errors', function (): void { $monorepoRoot = dirname(__DIR__, 4); - $composerBin = trim(shell_exec('which composer') ?: ''); + $composerBin = trim(shell_exec('command -v composer') ?: ''); if ($composerBin === '') { - $composerBin = '/opt/homebrew/bin/composer'; + test()->markTestSkipped('composer is not on PATH'); } $output = []; $exitCode = 0; exec( - '/opt/homebrew/Cellar/php/8.5.1_2/bin/php ' . escapeshellarg( - $composerBin + escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg( + $composerBin, ) . ' dump-autoload --ignore-platform-reqs 2>&1', $output, $exitCode, diff --git a/packages/session-file/tests/ModuleTest.php b/packages/session-file/tests/ModuleTest.php index dbee43f3..f294c541 100644 --- a/packages/session-file/tests/ModuleTest.php +++ b/packages/session-file/tests/ModuleTest.php @@ -71,9 +71,12 @@ } }); -it('orders the session middleware after page-cache when the file driver and page-cache are both installed', function (): void { - $module = require dirname(__DIR__) . '/module.php'; +it( + 'orders the session middleware after page-cache when the file driver and page-cache are both installed', + function (): void { + $module = require dirname(__DIR__) . '/module.php'; - expect($module['sequence'])->toHaveKey('after') - ->and($module['sequence']['after'])->toContain('marko/page-cache'); -}); + expect($module['sequence'])->toHaveKey('after') + ->and($module['sequence']['after'])->toContain('marko/page-cache'); + }, +); diff --git a/packages/session/tests/KnownDriversValidationTest.php b/packages/session/tests/KnownDriversValidationTest.php index 623814a3..1dff64a5 100644 --- a/packages/session/tests/KnownDriversValidationTest.php +++ b/packages/session/tests/KnownDriversValidationTest.php @@ -11,7 +11,7 @@ 'skeleton suggest block contains all session drivers', function () use ($knownDriversPath, $skeletonComposerPath) { KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); - } + }, ); test('every session driver follows marko slash prefix pattern', function () use ($knownDriversPath) { diff --git a/packages/session/tests/Unit/Middleware/SessionMiddlewareTest.php b/packages/session/tests/Unit/Middleware/SessionMiddlewareTest.php index 707e1ba1..5475a6a0 100644 --- a/packages/session/tests/Unit/Middleware/SessionMiddlewareTest.php +++ b/packages/session/tests/Unit/Middleware/SessionMiddlewareTest.php @@ -144,8 +144,7 @@ public function save(): void public function get( string $key, mixed $default = null, - ): mixed - { + ): mixed { return $default; } diff --git a/packages/skeleton/tests/KnownDriversSuggestParityTest.php b/packages/skeleton/tests/KnownDriversSuggestParityTest.php index 57c560a3..42f0d484 100644 --- a/packages/skeleton/tests/KnownDriversSuggestParityTest.php +++ b/packages/skeleton/tests/KnownDriversSuggestParityTest.php @@ -41,7 +41,7 @@ expect($skeletonSuggest)->toHaveKey('marko/database-readwrite') ->and($skeletonSuggest['marko/database-readwrite'])->toBe( - 'Read/write connection splitting decorator (optional — works alongside a base driver)' + 'Read/write connection splitting decorator (optional — works alongside a base driver)', ); }); @@ -53,7 +53,7 @@ expect($skeletonSuggest)->toHaveKey('marko/page-cache-entity') ->and($skeletonSuggest['marko/page-cache-entity'])->toBe( - 'Auto-purges page-cache tags on entity save/delete (optional add-on)' + 'Auto-purges page-cache tags on entity save/delete (optional add-on)', ); }); diff --git a/packages/skeleton/tests/PackageStructureTest.php b/packages/skeleton/tests/PackageStructureTest.php index 878cc59b..f4e3813e 100644 --- a/packages/skeleton/tests/PackageStructureTest.php +++ b/packages/skeleton/tests/PackageStructureTest.php @@ -212,13 +212,16 @@ expect($content)->toContain('Marko\Testing\TestCase'); }); -it('ships placeholders so every directory the phpunit.xml testsuites reference exists (app, modules, tests)', function (): void { - $base = __DIR__ . '/..'; +it( + 'ships placeholders so every directory the phpunit.xml testsuites reference exists (app, modules, tests)', + function (): void { + $base = __DIR__ . '/..'; - expect(is_dir($base . '/app'))->toBeTrue() - ->and(is_dir($base . '/modules'))->toBeTrue() - ->and(is_dir($base . '/tests'))->toBeTrue(); -}); + expect(is_dir($base . '/app'))->toBeTrue() + ->and(is_dir($base . '/modules'))->toBeTrue() + ->and(is_dir($base . '/tests'))->toBeTrue(); + }, +); it('runs pest successfully on a freshly scaffolded skeleton with no added test files', function (): void { // Simulate a fresh skeleton install: create a temporary directory with the @@ -237,8 +240,9 @@ $vendorAutoload = __DIR__ . '/../../../vendor/autoload.php'; $command = sprintf( - 'cd %s && /opt/homebrew/Cellar/php/8.5.1_2/bin/php -d memory_limit=512M %s -c %s --bootstrap %s --no-coverage 2>&1', + 'cd %s && %s -d memory_limit=512M %s -c %s --bootstrap %s --no-coverage 2>&1', escapeshellarg($tmpDir), + escapeshellarg(PHP_BINARY), escapeshellarg($vendorPest), escapeshellarg($tmpDir . '/phpunit.xml'), escapeshellarg($vendorAutoload), diff --git a/packages/testing/tests/Unit/Exceptions/AssertionFailedExceptionTest.php b/packages/testing/tests/Unit/Exceptions/AssertionFailedExceptionTest.php index e583adfd..52fedfaf 100644 --- a/packages/testing/tests/Unit/Exceptions/AssertionFailedExceptionTest.php +++ b/packages/testing/tests/Unit/Exceptions/AssertionFailedExceptionTest.php @@ -13,12 +13,12 @@ function (): void { context: 'some context', suggestion: 'some suggestion', ); - + expect($exception)->toBeInstanceOf(MarkoException::class) ->and($exception->getMessage())->toBe('Test assertion failed') ->and($exception->getContext())->toBe('some context') ->and($exception->getSuggestion())->toBe('some suggestion'); - } + }, ); it('creates AssertionFailedException with static factory methods for common assertion failures', function (): void { diff --git a/packages/testing/tests/Unit/TestCase/TestCaseTest.php b/packages/testing/tests/Unit/TestCase/TestCaseTest.php index ab34203e..0dfcc8bb 100644 --- a/packages/testing/tests/Unit/TestCase/TestCaseTest.php +++ b/packages/testing/tests/Unit/TestCase/TestCaseTest.php @@ -128,27 +128,33 @@ public function callRegister(): void expect($after2)->toBe($after1); }); -it('does not error and resolves nothing when run from a package dir whose root has no app or modules modules', function (): void { - // Simulate running from within the marko monorepo where vendor/ exists but app/ and modules/ do not - $monoRepoRoot = dirname(__DIR__, 5); // packages/testing/tests/Unit/TestCase -> monorepo root +it( + 'does not error and resolves nothing when run from a package dir whose root has no app or modules modules', + function (): void { + // Simulate running from within the marko monorepo where vendor/ exists but app/ and modules/ do not + $monoRepoRoot = dirname(__DIR__, 5); // packages/testing/tests/Unit/TestCase -> monorepo root - $tc = new class ('test') extends TestCase - { - public static string $root = ''; - - protected function projectRoot(): string + $tc = new class ('test') extends TestCase { - return self::$root; - } - }; + public static string $root = ''; - $tc::$root = $monoRepoRoot; + protected function projectRoot(): string + { + return self::$root; + } + }; - expect(fn () => $tc->setUp())->not->toThrow(Throwable::class); -}); + $tc::$root = $monoRepoRoot; -it('has registered the autoloaders by the time the test body runs so an App class resolves inside the test closure', function (): void { - // HomeService was registered by the setUp() of an earlier test in this process. - // This closure body executes after setUp(), confirming registration timing is sufficient. - expect(class_exists('App\Home\HomeService'))->toBeTrue(); -}); + expect(fn () => $tc->setUp())->not->toThrow(Throwable::class); + }, +); + +it( + 'has registered the autoloaders by the time the test body runs so an App class resolves inside the test closure', + function (): void { + // HomeService was registered by the setUp() of an earlier test in this process. + // This closure body executes after setUp(), confirming registration timing is sufficient. + expect(class_exists('App\Home\HomeService'))->toBeTrue(); + }, +); diff --git a/packages/translation/tests/Exceptions/NoDriverExceptionTest.php b/packages/translation/tests/Exceptions/NoDriverExceptionTest.php index fa74e0f6..e1fc3058 100644 --- a/packages/translation/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/translation/tests/Exceptions/NoDriverExceptionTest.php @@ -67,7 +67,7 @@ $exception = NoDriverException::noDriverInstalled(); expect($exception->getContext())->toContain( - 'Attempted to resolve a translation interface but no implementation is bound.' + 'Attempted to resolve a translation interface but no implementation is bound.', ); }); diff --git a/packages/translation/tests/KnownDriversValidationTest.php b/packages/translation/tests/KnownDriversValidationTest.php index eb1eca7e..1bdb07de 100644 --- a/packages/translation/tests/KnownDriversValidationTest.php +++ b/packages/translation/tests/KnownDriversValidationTest.php @@ -9,9 +9,9 @@ test( 'skeleton suggest block contains all translation drivers', - fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath) + fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath), ); test( 'every translation driver follows marko slash prefix pattern', - fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath) + fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath), ); diff --git a/packages/view-twig/src/ModuleLoader.php b/packages/view-twig/src/ModuleLoader.php index aca00c99..8ee82f42 100644 --- a/packages/view-twig/src/ModuleLoader.php +++ b/packages/view-twig/src/ModuleLoader.php @@ -60,8 +60,7 @@ public function getCacheKey(string $name): string public function isFresh( string $name, int $time, - ): bool - { + ): bool { $path = $this->resolvePath($name); return filemtime($path) <= $time; diff --git a/packages/view/tests/Exceptions/NoDriverExceptionTest.php b/packages/view/tests/Exceptions/NoDriverExceptionTest.php index fec7b508..f19552d6 100644 --- a/packages/view/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/view/tests/Exceptions/NoDriverExceptionTest.php @@ -78,7 +78,7 @@ $exception = NoDriverException::noDriverInstalled(); expect($exception->getContext())->toContain( - 'Attempted to resolve ViewInterface but no implementation is bound.' + 'Attempted to resolve ViewInterface but no implementation is bound.', ); }); diff --git a/packages/view/tests/Feature/CrossEngineTemplateParityTest.php b/packages/view/tests/Feature/CrossEngineTemplateParityTest.php index 688e1960..caeed98a 100644 --- a/packages/view/tests/Feature/CrossEngineTemplateParityTest.php +++ b/packages/view/tests/Feature/CrossEngineTemplateParityTest.php @@ -25,7 +25,7 @@ function extractEngineSuffix(string $packageName, string $parentName): ?string if (!is_dir($packagesDir) || basename($packagesDir) !== 'packages') { $this->markTestSkipped( - 'Not running inside the monorepo packages directory — skipping cross-engine parity check.' + 'Not running inside the monorepo packages directory — skipping cross-engine parity check.', ); } @@ -64,7 +64,7 @@ function extractEngineSuffix(string $packageName, string $parentName): ?string $description = $engineMeta['description'] ?? $engineName; $message = "Parent module '$parent' has template providers for [" . implode( ', ', - array_keys($foundEngines) + array_keys($foundEngines), ) . "] but is missing a provider for engine '$engineName' ($description). " . "Expected a package like 'marko/" . basename($parent) . "-$engineName' declaring " @@ -174,16 +174,16 @@ function extractEngineSuffix(string $packageName, string $parentName): ?string 'it ignores packages whose extracted suffix is not in known-engines (e.g., admin-panel-twig-extra produces suffix twig-extra and is skipped if not registered)', function () { $enginesPath = dirname(__DIR__, 2) . '/known-engines.php'; - + if (!file_exists($enginesPath)) { $this->markTestSkipped('known-engines.php not found — marko/view not installed standalone?'); } - + $engines = require $enginesPath; - + $suffix = extractEngineSuffix('marko/admin-panel-twig-extra', 'marko/admin-panel'); - + expect($suffix)->toBe('twig-extra') ->and(isset($engines[$suffix]))->toBeFalse(); - } + }, ); diff --git a/packages/view/tests/KnownDriversValidationTest.php b/packages/view/tests/KnownDriversValidationTest.php index 2ee95b86..2a5c93d6 100644 --- a/packages/view/tests/KnownDriversValidationTest.php +++ b/packages/view/tests/KnownDriversValidationTest.php @@ -11,7 +11,7 @@ 'skeleton suggest block contains all view drivers', function () use ($knownDriversPath, $skeletonComposerPath): void { KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); - } + }, ); test('every view driver follows marko slash prefix pattern', function () use ($knownDriversPath): void { diff --git a/phpcs.xml b/phpcs.xml index 26dc798a..d357ba23 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -8,6 +8,13 @@ */vendor/* */.phpunit.cache/* + + */tests/Fixtures/*/src/Broken/* + diff --git a/phpstan.neon b/phpstan.neon index 1a3b4c82..48c9e560 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,4 +4,13 @@ parameters: - packages/core/src excludePaths: - packages/core/tests + ignoreErrors: + # Every runtime consumer of PluginInterception is an eval-generated interceptor class, + # so no source-visible `use` exists for PHPStan to find and it reports the trait as + # unused. The trait is genuinely used; this is a limitation of analysing generated code, + # not a defect. Tracked for removal in favour of a collaborator object — see the + # "No traits" note in the trait's own docblock. + - + identifier: trait.unused + path: packages/core/src/Plugin/PluginInterception.php tmpDir: .phpstan-cache diff --git a/tests/CiWorkflowTest.php b/tests/CiWorkflowTest.php new file mode 100644 index 00000000..0df14f3e --- /dev/null +++ b/tests/CiWorkflowTest.php @@ -0,0 +1,100 @@ +toBeTrue('.github/workflows/ci.yml must exist'); +}); + +it('gates every pull request, not just those touching certain paths', function () use ($ci): void { + // A paths: filter here would let a PR skip the gate entirely. + $trigger = substr($ci, 0, (int) strpos($ci, 'jobs:')); + + expect($trigger) + ->toContain('pull_request') + ->not->toContain('paths:'); +}); + +it('also runs on pushes to develop and main', function () use ($ci): void { + expect($ci) + ->toContain('branches:') + ->toContain('- develop') + ->toContain('- main'); +}); + +it('runs tests, lint, and static analysis as separate jobs', function () use ($ci): void { + expect($ci) + ->toContain('name: Tests') + ->toContain('name: Lint') + ->toContain('name: Static analysis') + ->toContain('composer test') + ->toContain('phpcs --standard=phpcs.xml') + ->toContain('php-cs-fixer fix --config=.php-cs-fixer.php --dry-run') + ->toContain('composer phpstan'); +}); + +it('pins PHP 8.5 in every job', function () use ($ci): void { + expect(substr_count($ci, "php-version: '8.5'"))->toBe(substr_count($ci, 'runs-on: ubuntu-latest')); +}); + +it('pins actions to a major version rather than a floating ref', function () use ($ci, $nightly): void { + preg_match_all('/uses: (\S+)/', $ci . $nightly, $matches); + + expect($matches[1])->not->toBeEmpty(); + + foreach ($matches[1] as $action) { + expect($action)->toMatch('/@v\d+$/'); + } +}); + +it('runs the destructive group on a schedule instead of on every PR', function () use ($ci, $nightly): void { + expect($nightly) + ->toContain('schedule:') + ->toContain('cron:') + ->toContain('composer test:all') + ->and($ci)->toContain('composer test') + ->and($ci)->not->toContain('test:all'); +}); + +it('exposes composer phpstan and composer ci scripts the workflow depends on', function (): void { + $composer = json_decode(file_get_contents(dirname(__DIR__) . '/composer.json'), true); + + expect($composer['scripts'])->toHaveKey('phpstan') + ->and($composer['scripts'])->toHaveKey('ci') + ->and($composer['scripts']['ci'])->toContain('@test') + ->and($composer['scripts']['ci'])->toContain('@phpstan'); +}); + +it('excludes deliberately-unparseable fixtures from both linters', function (): void { + // php-cs-fixer lints before fixing and exits 4 on invalid PHP even with nothing to change; + // phpcs aborts on the file. Either would fail the Lint job forever. + $csFixer = file_get_contents(dirname(__DIR__) . '/.php-cs-fixer.php'); + $phpcs = file_get_contents(dirname(__DIR__) . '/phpcs.xml'); + + $brokenFixtures = [ + 'packages/config/tests/Unit/fixtures/syntax-error.php', + 'packages/codeindexer/tests/Fixtures/AttributeFixtures/src/Broken/SyntaxError.php', + 'packages/codeindexer/tests/Fixtures/ConfigFixtures/module-d/config/broken.php', + ]; + + foreach ($brokenFixtures as $fixture) { + expect(file_exists(dirname(__DIR__) . '/' . $fixture))->toBeTrue("$fixture should still exist"); + expect($csFixer)->toContain(str_replace('packages/', '', $fixture)); + } + + expect($phpcs)->toContain('src/Broken/'); +}); + +it('adds phpstan to the PR review checklist that previously omitted it', function (): void { + $process = file_get_contents(dirname(__DIR__) . '/.claude/pr-review-process.md'); + + expect($process) + ->toContain('composer phpstan') + ->toContain('PHPStan is not optional') + ->not->toContain('No PR CI workflow exists in this repo'); +});