Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
14 changes: 12 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
55 changes: 46 additions & 9 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
166 changes: 166 additions & 0 deletions tests/DeclaredRequirementsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

declare(strict_types=1);

/**
* Every Illuminate component that src/ uses is declared in `require`.
*
* Testbench installs the whole framework, so an undeclared component passes every test here and
* fails only for a consumer whose install lacks it. "Uses" is read three ways, because a facade or a
* helper reaches a component without importing it:
*
* - an `Illuminate\<Component>\...` 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<string, string> 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<string, string|null> 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<string> 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<string> global helpers one line of source calls */
function validationHelperCalls(string $line): array
{
$bare = (string) preg_replace(["/'(?:\\\\.|[^'\\\\])*'/", '/"(?:\\\\.|[^"\\\\])*"/'], "''", $line);
preg_match_all('/(?<![\w>:$\\\\])(?<!function )([a-z_]+)\s*\(/', $bare, $calls);

return array_values(array_filter($calls[1], static fn (string $call): bool => array_key_exists($call, VALIDATION_HELPERS)));
}

/** @return array<string, list<string>> package => evidence ("file:line") */
function validationUsedIlluminatePackages(): array
{
$root = dirname(__DIR__);
$used = [];
$files = 0;

/** @var iterable<SplFileInfo> $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<string, string>} $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));
});
7 changes: 6 additions & 1 deletion tests/RuleMessagesResolveTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}

/**
Expand Down
Loading