diff --git a/CHANGELOG.md b/CHANGELOG.md index 880edcf..9aa114d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se `vendor/laranail/package-tools/pint.json`. The job now installs dependencies and runs `composer pint-fix`, which is the same command the `lint` gate checks. 259 files are reformatted by this change; no behaviour is affected (6276 tests, 10486 assertions, identical before and after). +- **`RuleMessagesResolveTest` asserted that the dashed translation namespace does not resolve.** + `laranail/package-tools` 0.1.3 made `hasTranslations()` register `laranail-validation` as an alias + of `laranail/validation` over the same files, so against fresh dependencies the test went red on + `main` with no change here. It now asserts the alias resolves to exactly the canonical message, + and `require` raises `laranail/package-tools` to `^0.1.3` so a `prefer-lowest` resolve gets a + version that registers it. ### Changed @@ -34,6 +40,22 @@ Entries below `Unreleased` are written by CI from the GitHub release body — se (`Redirector`). It arrived only transitively before. - Dropped the `vcs` repositories for `laranail/atlas` and `laranail/enumerator`: nothing in this package's `require` or `require-dev` closure installs them (checked with `composer why` after a fresh `composer update`). +- `require` now declares every Illuminate component `src/` uses: `illuminate/auth`, + `illuminate/cache`, `illuminate/config`, `illuminate/container`, `illuminate/database`, + `illuminate/encryption`, `illuminate/events`, `illuminate/filesystem` and + `illuminate/translation`, plus `laravel/framework ^13.0`. `FluentFormRequest` extends + `Illuminate\Foundation\Http\FormRequest`, which has no split package, and the global helpers + `src/` calls (`config()`, `app()`, `trans()`, `event()`, `config_path()`, ...) are defined only in + `Illuminate/Foundation/helpers.php`. All of them arrived only transitively before. + `tests/DeclaredRequirementsTest.php` scans `src/` and fails on any use the manifest does not + declare. +- `require-dev` raises `tomasvotruba/cognitive-complexity` to `^1.3`, and the PHPStan baseline + records the values 1.3.0 measures. 1.3.0 (2026-09-29) scores the same source differently from + 1.2.0, mostly higher (`RuleSet` 141 -> 157, `RuleConfigBuilder::buildValueClosure()` 52 -> 76, + ...), so every complexity entry stopped matching and the required `phpstan` check went red with no + code change. Each entry is re-measured, none is removed, and four newly reported ones + (`BatchDatabaseChecker` and `DoctorCommand::handle()`) are added at their measured values. The + floor keeps a prefer-lowest resolve on the same scale; no limit was raised. ### Added diff --git a/composer.json b/composer.json index 9b67dec..7cecec7 100644 --- a/composer.json +++ b/composer.json @@ -67,13 +67,23 @@ }, "require": { "php": "^8.5", + "illuminate/auth": "^13.0", + "illuminate/cache": "^13.0", + "illuminate/config": "^13.0", + "illuminate/container": "^13.0", "illuminate/contracts": "^13.0", + "illuminate/database": "^13.0", + "illuminate/encryption": "^13.0", + "illuminate/events": "^13.0", + "illuminate/filesystem": "^13.0", "illuminate/http": "^13.0", "illuminate/routing": "^13.0", "illuminate/support": "^13.0", + "illuminate/translation": "^13.0", "illuminate/validation": "^13.0", - "laranail/package-tools": "^0.1", "laranail/console": "^0.1", + "laranail/package-tools": "^0.1.3", + "laravel/framework": "^13.0", "symfony/process": "^7.4 || ^8.0" }, "require-dev": { @@ -99,7 +109,7 @@ "sandermuller/package-boost-laravel": "^1.0", "spaze/phpstan-disallowed-calls": "^4.10", "symplify/phpstan-extensions": "^12.0.2", - "tomasvotruba/cognitive-complexity": "^1.1", + "tomasvotruba/cognitive-complexity": "^1.3", "tomasvotruba/type-coverage": "^2.3.4" }, "suggest": { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 680be8f..ff033c7 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,17 @@ parameters: ignoreErrors: + # Every complexity.* value below was re-measured on 2026-10-05 with + # tomasvotruba/cognitive-complexity 1.3.0, whose scoring differs from + # 1.2.0 (which the earlier numbers in the comments below were written + # against). No source changed between the two measurements, so the + # difference is the scorer's alone; composer.json floors the package at + # ^1.3 so a prefer-lowest resolve scores on the same scale. + # 1.2.0 -> 1.3.0: RuleConfigBuilder class 118 -> 155, buildValueClosure + # 52 -> 76; ItemContextCompiler class 98 -> 125, buildItemAwareClosure + # 59 -> 86; PresenceConditionalCompiler::compile 25 -> 24; RuleSet class + # 141 -> 157; ItemRuleCompiler class 88 -> 111, buildFastChecks 46 -> 68; + # ItemValidator::validate 49 -> 64. BatchDatabaseChecker and + # DoctorCommand::handle were under the limits on 1.2.0 and are new here. # Hot-path closure builders: complexity comes from intentional # inlining (size, in/regex, date comparisons) to avoid closure # allocation + call overhead on every validated item. @@ -15,31 +27,31 @@ parameters: # was extracted, and addDateChecks() dropped under its own limit — # its entry is gone. - - rawMessage: 'Class cognitive complexity is 118, keep it under 80' + rawMessage: 'Class cognitive complexity is 155, keep it under 80' identifier: complexity.classLike count: 1 path: src/FastCheck/RuleConfigBuilder.php - - rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\FastCheck\RuleConfigBuilder::buildValueClosure()" is 52, keep it under 20' + rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\FastCheck\RuleConfigBuilder::buildValueClosure()" is 76, keep it under 20' identifier: complexity.functionLike count: 1 path: src/FastCheck/RuleConfigBuilder.php - - rawMessage: 'Class cognitive complexity is 98, keep it under 80' + rawMessage: 'Class cognitive complexity is 125, keep it under 80' identifier: complexity.classLike count: 1 path: src/FastCheck/ItemContextCompiler.php - - rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\FastCheck\ItemContextCompiler::buildItemAwareClosure()" is 59, keep it under 20' + rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\FastCheck\ItemContextCompiler::buildItemAwareClosure()" is 86, keep it under 20' identifier: complexity.functionLike count: 1 path: src/FastCheck/ItemContextCompiler.php - - rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\FastCheck\PresenceConditionalCompiler::compile()" is 25, keep it under 20' + rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\FastCheck\PresenceConditionalCompiler::compile()" is 24, keep it under 20' identifier: complexity.functionLike count: 1 path: src/FastCheck/PresenceConditionalCompiler.php @@ -67,7 +79,7 @@ parameters: # missing-dependency guard. The after() route itself lives in # Internal\VanillaAfterRoute, not here. - - rawMessage: 'Class cognitive complexity is 141, keep it under 80' + rawMessage: 'Class cognitive complexity is 157, keep it under 80' identifier: complexity.classLike count: 1 path: src/RuleSet.php @@ -78,7 +90,7 @@ parameters: # for native-parity. Dropping the class under 80 is a whole-class refactor # out of scope. - - rawMessage: 'Class cognitive complexity is 88, keep it under 80' + rawMessage: 'Class cognitive complexity is 111, keep it under 80' identifier: complexity.classLike count: 1 path: src/Internal/ItemRuleCompiler.php @@ -87,7 +99,7 @@ parameters: # flat × item-aware vs value-only) — structural complexity that # refactoring would only move. - - rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\Internal\ItemRuleCompiler::buildFastChecks()" is 46, keep it under 20' + rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\Internal\ItemRuleCompiler::buildFastChecks()" is 68, keep it under 20' identifier: complexity.functionLike count: 1 path: src/Internal/ItemRuleCompiler.php @@ -99,11 +111,36 @@ parameters: # carries the score, and refactoring those caches has measurable # perf cost (per-item validator reuse + dispatch reduction). - - rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\Internal\ItemValidator::validate()" is 49, keep it under 20' + rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\Internal\ItemValidator::validate()" is 64, keep it under 20' identifier: complexity.functionLike count: 1 path: src/Internal/ItemValidator.php + # New on cognitive-complexity 1.3.0 (under the limits on 1.2.0, same code). + - + rawMessage: 'Class cognitive complexity is 100, keep it under 80' + identifier: complexity.classLike + count: 1 + path: src/BatchDatabaseChecker.php + + - + rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\BatchDatabaseChecker::collectValues()" is 22, keep it under 20' + identifier: complexity.functionLike + count: 1 + path: src/BatchDatabaseChecker.php + + - + rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\BatchDatabaseChecker::collectExpandedValues()" is 23, keep it under 20' + identifier: complexity.functionLike + count: 1 + path: src/BatchDatabaseChecker.php + + - + rawMessage: 'Cognitive complexity for "Simtabi\Laranail\Validation\Commands\DoctorCommand::handle()" is 23, keep it under 20' + identifier: complexity.functionLike + count: 1 + path: src/Commands/DoctorCommand.php + # Cross-version Filament guard. The trait may be mixed into components # that lack getCachedForms (plain Livewire without InteractsWithForms), # so the method_exists() check is required at runtime. PHPStan only ever diff --git a/tests/DeclaredRequirementsTest.php b/tests/DeclaredRequirementsTest.php new file mode 100644 index 0000000..00cafa8 --- /dev/null +++ b/tests/DeclaredRequirementsTest.php @@ -0,0 +1,166 @@ +\...` name, imported or fully qualified; + * - a facade from `Illuminate\Support\Facades`, mapped to the component behind it; + * - a global helper. Every global helper (`config()`, `app()`, `__()`, `now()`, `report()`, ...) is + * defined in `Illuminate/Foundation/helpers.php`, which only `laravel/framework` autoloads, so a + * helper call needs `laravel/framework` as well as the component it reaches. + * + * `Illuminate\Foundation` has no split package, so its declaration is `laravel/framework`, which + * "replaces" every `illuminate/*` split and so is consistent with requiring them too (estate-followups + * decision D3). A line carrying a `class_exists()`-style guard is skipped: that use is optional. + * Measured 2026-10-05 (tooling-hygiene audit H6). + */ + +/** @var array facade => package */ +const VALIDATION_FACADES = [ + 'App' => 'illuminate/container', 'Artisan' => 'illuminate/console', 'Auth' => 'illuminate/auth', + 'Blade' => 'illuminate/view', 'Broadcast' => 'illuminate/broadcasting', 'Bus' => 'illuminate/bus', + 'Cache' => 'illuminate/cache', 'Config' => 'illuminate/config', 'Context' => 'illuminate/log', + 'Cookie' => 'illuminate/cookie', 'Crypt' => 'illuminate/encryption', 'Date' => 'illuminate/support', + 'DB' => 'illuminate/database', 'Event' => 'illuminate/events', 'Facade' => 'illuminate/support', + 'File' => 'illuminate/filesystem', 'Gate' => 'illuminate/auth', 'Hash' => 'illuminate/hashing', + 'Http' => 'illuminate/http', 'Lang' => 'illuminate/translation', 'Log' => 'illuminate/log', + 'Mail' => 'illuminate/mail', 'Notification' => 'illuminate/notifications', 'Password' => 'illuminate/auth', + 'Pipeline' => 'illuminate/pipeline', 'Process' => 'illuminate/process', 'Queue' => 'illuminate/queue', + 'RateLimiter' => 'illuminate/cache', 'Redirect' => 'illuminate/routing', 'Redis' => 'illuminate/redis', + 'Request' => 'illuminate/http', 'Response' => 'illuminate/routing', 'Route' => 'illuminate/routing', + 'Schedule' => 'illuminate/console', 'Schema' => 'illuminate/database', 'Session' => 'illuminate/session', + 'Storage' => 'illuminate/filesystem', 'URL' => 'illuminate/routing', 'Validator' => 'illuminate/validation', + 'View' => 'illuminate/view', 'Vite' => 'laravel/framework', +]; + +/** @var array helper => package it reaches (null: Foundation itself) */ +const VALIDATION_HELPERS = [ + 'abort' => null, 'abort_if' => null, 'abort_unless' => null, 'app' => 'illuminate/container', + 'app_path' => null, 'auth' => 'illuminate/auth', 'base_path' => null, 'bcrypt' => 'illuminate/hashing', + 'cache' => 'illuminate/cache', 'config' => 'illuminate/config', 'config_path' => null, + 'database_path' => null, 'dispatch' => 'illuminate/bus', 'event' => 'illuminate/events', + 'info' => 'illuminate/log', 'lang_path' => null, 'logger' => 'illuminate/log', 'now' => 'illuminate/support', + 'public_path' => null, 'redirect' => 'illuminate/routing', 'report' => null, 'request' => 'illuminate/http', + 'rescue' => null, 'resolve' => 'illuminate/container', 'resource_path' => null, + 'response' => 'illuminate/routing', 'route' => 'illuminate/routing', 'session' => 'illuminate/session', + 'storage_path' => null, 'to_route' => 'illuminate/routing', 'today' => 'illuminate/support', + 'trans' => 'illuminate/translation', 'trans_choice' => 'illuminate/translation', '__' => 'illuminate/translation', + 'url' => 'illuminate/routing', 'validator' => 'illuminate/validation', 'view' => 'illuminate/view', +]; + +/** Source with comments blanked out, line numbers kept: comments carry names that are not uses. */ +function validationCodeWithoutComments(string $path): string +{ + $code = ''; + + foreach (token_get_all((string) file_get_contents($path)) as $token) { + if (! is_array($token)) { + $code .= $token; + + continue; + } + + $code .= in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true) + ? str_repeat("\n", substr_count($token[1], "\n")) + : $token[1]; + } + + return $code; +} + +/** @return list packages one line of source reaches through an `Illuminate\...` name */ +function validationNamedPackages(string $line): array +{ + preg_match_all('/Illuminate\\\\([A-Za-z]+)\\\\(?:Facades\\\\([A-Za-z]+))?/', $line, $names, PREG_SET_ORDER); + + $packages = []; + + foreach ($names as $name) { + $facade = $name[2] ?? ''; + + $packages[] = match (true) { + $name[1] === 'Support' && $facade !== '' => VALIDATION_FACADES[$facade] ?? 'unmapped facade ' . $facade, + $name[1] === 'Foundation' => 'laravel/framework', + default => 'illuminate/' . strtolower($name[1]), + }; + } + + return $packages; +} + +/** @return list global helpers one line of source calls */ +function validationHelperCalls(string $line): array +{ + $bare = (string) preg_replace(["/'(?:\\\\.|[^'\\\\])*'/", '/"(?:\\\\.|[^"\\\\])*"/'], "''", $line); + preg_match_all('/(?:$\\\\])(? array_key_exists($call, VALIDATION_HELPERS))); +} + +/** @return array> package => evidence ("file:line") */ +function validationUsedIlluminatePackages(): array +{ + $root = dirname(__DIR__); + $used = []; + $files = 0; + + /** @var iterable $iterator */ + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root . '/src', FilesystemIterator::SKIP_DOTS)); + + foreach ($iterator as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $files++; + $relative = substr($file->getPathname(), strlen($root) + 1); + + foreach (explode("\n", validationCodeWithoutComments($file->getPathname())) as $index => $line) { + // A guarded line is an optional use. + if (preg_match('/\b(class|interface|trait|function)_exists\s*\(/', $line) === 1) { + continue; + } + + $where = $relative . ':' . ($index + 1); + + foreach (validationNamedPackages($line) as $package) { + $used[$package][] = $where; + } + + foreach (validationHelperCalls($line) as $helper) { + $used['laravel/framework'][] = $where . ' (' . $helper . '())'; + + if (VALIDATION_HELPERS[$helper] !== null) { + $used[VALIDATION_HELPERS[$helper]][] = $where . ' (' . $helper . '())'; + } + } + } + } + + // Non-vacuity: a scan that found no source, or no use, proves nothing about it. + expect($files)->toBeGreaterThanOrEqual(200) + ->and(count($used))->toBeGreaterThanOrEqual(13); + + return $used; +} + +it('declares every illuminate component that src/ uses', function (): void { + /** @var array{require: array} $composer */ + $composer = json_decode((string) file_get_contents(__DIR__ . '/../composer.json'), true, flags: JSON_THROW_ON_ERROR); + + $missing = []; + + foreach (validationUsedIlluminatePackages() as $package => $where) { + if (! array_key_exists($package, $composer['require'])) { + $missing[$package] = $where[0]; + } + } + + expect($missing)->toBeEmpty('Used by src/ but not declared in require: ' . json_encode($missing, JSON_UNESCAPED_SLASHES)); +}); diff --git a/tests/RuleMessagesResolveTest.php b/tests/RuleMessagesResolveTest.php index 44c7f49..ead05fb 100644 --- a/tests/RuleMessagesResolveTest.php +++ b/tests/RuleMessagesResolveTest.php @@ -67,11 +67,16 @@ public function test_the_namespace_is_the_composer_package_name(): void // translator's hasForLocale(): that method is on the concrete // Translator and not on the contract, so reaching it means either a // string container key or a type-hint that does not declare it. + // + // The dashed form is laranail/package-tools' alias (registered by + // hasTranslations() since package-tools 0.1.3), so a host that published + // or wrote the older spelling still gets the same sentence. It must + // resolve to exactly what the canonical name does, never to its own copy. $slashed = 'laranail/validation::validation.iban'; $dashed = 'laranail-validation::validation.iban'; $this->assertNotSame($slashed, trans($slashed), 'The composer-package namespace did not resolve.'); - $this->assertSame($dashed, trans($dashed), 'The dashed namespace resolved, so both are registered.'); + $this->assertSame(trans($slashed), trans($dashed), 'The dashed alias does not resolve to the canonical message.'); } /**