Skip to content

hygiene.__invokeCall: required, calling an invokable object through __invoke() - #7

Closed
kaskader201 wants to merge 47 commits into
dg:masterfrom
kaskader201:invoke-call-required
Closed

kaskader201 wants to merge 47 commits into
dg:masterfrom
kaskader201:invoke-call-required

Conversation

@kaskader201

@kaskader201 kaskader201 commented Nov 1, 2026 •

Copy link
Copy Markdown
Contributor

hygiene.__invokeCall: required, calling an invokable object through __invoke()

hygiene.__invokeCall takes only forbidden and keep. This PR adds the opposite state, required:

$handler($x);              // $handler->__invoke($x);
($this->handler)($x);      // $this->handler->__invoke($x);
(new Handler)($x);         // (new Handler)->__invoke($x);
$registry->find()($x);     // $registry->find()->__invoke($x);

Motivation. With an explicit __invoke(), go to definition and find usages in the IDE reach the __invoke() declaration, and a text search finds the calls of a handler. $handler($x) gives neither.

What changes

  • hygiene.__invokeCall becomes Domain::state('forbidden', 'required'), in the same shape as ShortTernaryRule.
  • Rename. The rule now has two states, so NoExplicitInvokeCallsRule is renamed to InvokeCallNotationRule (a …Notation rule writes one form of a family). Its fixtures move with it.
  • forbidden is unchanged. Its output is byte for byte the same, and the existing fixtures pass without edits.
  • required needs the types. A call is rewritten only where Analyses\Types shows the callee to be an object for sure, every class it may be has __invoke(), and none of them is Closure. Without typeAnalysis: phpstan the state reports nothing. The rule is not typesRequired, because forbidden does not need the types.
  • What stays untouched, and why:
    • a callee of unknown or mixed type, callable, a string or an array: it may be any kind of callable, and ->__invoke() would fail on most of them
    • Closure, and a union containing it: the __invoke() of a Closure leads to no declaration in the project, so it gains nothing
    • a nullable object: the types do not show an object for sure
    • $f(...): it makes a Closure of the object, not a call
    • a function called by name, strlen($x)
    • a call where the rewrite would lose a comment between the callee, its parentheses and the arguments
  • The parentheses of ($this->h) are dropped, and (new Handler) keeps its own. Multi-line arguments and their comments stay as written.
  • Interop. Neither PHP_CodeSniffer (Slevomat) nor PHP CS Fixer has a sniff that requires an explicit __invoke(), so nothing new maps to required. SlevomatCodingStandard.PHP.DisallowDirectMagicInvokeCall still maps to forbidden.
  • docs/dresscode.annotated.neon lists required. composer reference and composer api were regenerated.

Tests

  • New fixture invokeCallNotation/required with stubs: a variable, a property in parentheses, new, the result of a method call and of a static one, a union of two invokables, a call nested in the arguments, multi-line arguments with a comment, and the comment guard. It also covers the untouched cases: Closure, callable, an unknown type, a string, a class without __invoke(), a nullable object, a union with Closure, $f(...) and an existing ->__invoke().
  • composer phpstan is clean and bin/dresscode check is clean.
  • composer tester: 5 tests fail locally, all because my local PHP lacks simplexml (Interop/FormatterConfig, Interop/Translator, Upgrade/PlanRunner, Console/init, Console/Application). In a php:8.4-cli container with SimpleXML, Interop and Console/init pass. The remaining two fail there for reasons of the container itself (a git hook refusing the commit, running as root) and have nothing to do with this rule.

🤖 Generated with Claude Code

@dg
dg force-pushed the master branch 5 times, most recently from 421c1d0 to 487280f Compare November 2, 2026 01:42
dg added 25 commits November 2, 2026 02:43
A file of over a thousand lines is hard to read, so the workers got one of their own, as the commands coming next
do. What the tests share went to Application.helpers.php.
…uration naming it

A baseline the configuration names must exist, and the header of check names the one in use. Baseline finds, opens and
names its file itself, the default name one constant of its own; RunnerFactory and CheckCommand only call it.
…abel()

Its width is Markup::LabelWidth; the header of a run, check, fix and config use it.
With the functions of the container configurator of Symfony among those the packages bring.
PER Coding Style with what Pint 1.32 decides otherwise and without what it leaves to the author: the concatenation
without spaces, `! $x`, `new Foo`, `fn ($x) =>`, the trailing comma in arrays alone, a blank line before `return`
and `continue`, the enum cases kept together, the names in doc comments imported, and no construct spread for its
width. A standard of the core beside the others, so the reference and explain list it with them.
The rules of PHP CS Fixer, PHP_CodeSniffer and Slevomat translated to the decisions of DressCode, their options
included, a rule switched off making what it stands for keep, and what cannot be carried over said; the sets of
PHP CS Fixer and of PHP_CodeSniffer translate to the standards, @symfony to symfony. A translation is written as a
Config\Draft of the configuration, as NEON or PHP, each key with its comment, and a configuration file of PHP CS
Fixer is run in a process of its own after the autoload of its project (Engine\ProcessRunner).
…PHP_CodeSniffer or Laravel Pint

It writes it as dresscode.neon, or as the NEON or PHP file --output names, never over an existing one, and says what
it could not carry over. A pint.json is read as data, its preset the standard of that name and its rules those of
PHP CS Fixer.
…ering it

The catalogue, explain and the reference list the names of the other tools each decision covers, and such a name
written as a key, in only, fixRisky or severity is refused with the decisions that cover it, pointing to migrate config.
A name of another tool stands for the decisions covering it.

phpcs:ignore, phpcs:disable, phpcs:enable, phpcs:ignoreFile and the annotation @phpcsSuppress silence what
Interop translates the name of the rule to, a message of a sniff standing for its own row of the table.
…ct has

Interop\FormatterSearch finds where a project keeps the configuration of its formatter and chooses it by the evidence
that the project runs it, a script of composer.json, then a package it requires, then the file alone, a terminal asking
among several; a file of PHP CS Fixer, which reading runs, is read only after the user agrees.
…d holds

Without git the scope is what the autoload and the usual directories hold.

Measuring\Scope leaves out the data of tests and what the defaults exclude, and with git a file linguist-generated
marks; every path it leaves out the run says is a change. The configuration is written into the root of the project
alone, the paths it names being relative to it.
Measuring\Scope takes the paths of the formatter (<file> of PHP_CodeSniffer, ->in() of PHP CS Fixer, read from its syntax
tree by Interop\PhpCsFixerSource) where its configuration names them, leaves out what it excludes and says what of its
finder it could not read.
… of a sniff

They keep the decisions of that sniff there.
For migrate suppressions to rewrite, counting every form the rewrite takes.
In place of the directory of the configuration, so that a configuration written elsewhere runs over the project with its
composer.json.

21def661-e3da-4778-b400-5c949a404d2e
It counts by decision what disagrees with the configuration and what a fix would change.

ProcessProbeRunner runs the probes as processes of this DressCode, a check of each configuration with --root over the
project, concurrently and never a probe within a probe; InProcessProbeRunner runs them in the process. Measurement
reads what the code says of a decision from the counts under each of its values (Classification).
dresscode init measures the code of a project and writes the dresscode.neon that fits it. The scope is what the formatter
of the project checks where its configuration names the paths, else what git tracks, written as the entries of the root
that hold it, else what the autoload of composer.json names; what it leaves out goes to excludePaths with the reason: a
file a generator wrote, the data of tests, what git ignores inside the paths and a file that does not parse. The sample
is shared among the directories. It chooses the standard under which a fix alone changes the fewest files of the sample,
the one the formatter names, then perCs deciding a close call, laravel checked only for a project of Laravel or Laravel
Pint. It measures the residue of that standard, every count a probe, a check of a configuration run as a process of this
DressCode, concurrently: the indentation, the width of a line and the line ending first, then each decision under the
values its domain tells. It writes what the code holds to and leaves unchecked what it does not decide, checks the
configuration over the whole scope and leaves unchecked the decisions the others change least first until the first fix
changes no more files than the budget, and names the baseline for what no rule fixes. --set writes a decision whatever
the code says of it, --dry-run prints the configuration, --formatter names the configuration of the formatter and
--format json writes the proposal as data; over a configuration that exists init writes nothing and says what has moved
since or what the file sets apart from the standard the code holds to.
Where a fix under perCs changes at most half again as many files and the formatter does not name psr12, PSR-12 saying
nothing of what PHP brought since.
…of their own

The spinner turns through the one long probe of the check of the whole scope, and the progress bar of check turns it
beside a slow file in place of counting its seconds, the wait for the workers lasting at most 0.1 s while the progress
is drawn.
dg added 21 commits November 2, 2026 02:43
A close call of standards first, then in rounds the decisions over the budget, contradicted by the formatter or naming
names that share an affix for naming.except, the answers numbered with the recommended one first, which Enter takes.

The first round asks the five costliest, a later one what the answers left over the budget once the configuration is
checked again; an answer that keeps the value of the code writes it as the user's, which the budget then leaves alone.
Without a terminal, with --yes and with --format json it takes the recommended answers, which the JSON lists with how to
give another. What the standard as it is would rewrite is said on the line Measured.
A tree with millions of them no longer runs out of memory.

`fix` and `check` over 1.8 million violations held every one of them to the end of the run, about 1.5 GB in the parent,
only to count them; the limit of 512 MB the program sets ends such a run sooner. A reporter still sees each file whole
in `reportFile()`, and what a run keeps of it is `ViolationTally`. The baseline and the probes need
the violations one by one, so they take them through `CollectingReporter`.

b0961ab5-dcf4-44a9-a919-74d28c9f3060
…uires

In the style the project keeps.

dresscode upgrade today lays the set deprecations, with --modernize also modernizations, over the presets the
configuration uses and beneath its own decisions and overrides, so that what the project keeps stays kept, lets in the
plugins of the packages of upgrading data, adds the types of the code and fixes. Without a configuration file it takes
the scope init would write and no style. With one it keeps the scope, the target, the types, fixRisky, the baseline and
the maps, and of the style only the decisions the code keeps: a check without the types and the sets runs first, every
decision it finds violated anywhere is left out, and the run is narrowed by only to the rest, the sets and the packages
whose data reach the project, which a worker gets through --only. It takes --only, --fix-risky and --ask-risky. The
upgrade goes to the lowest versions of PHP and of the libraries the project allows; the console says which libraries it
takes, which packages are installed newer than that, whose declarations then say nothing of what it deprecates, advising
composer update --prefer-lowest, and, where it rewrote a file, to run it again. The files a generator wrote are left out,
and the console says how many.
…alled beside DressCode

A package of upgrading data says which packages it is about under extra.dresscode.packages, and
UpgradingTester::collectPackageProblems() refuses a list that is not exactly the packages its upgrading files are about.
upgrade today learns of the packages from Packagist, asked once a day and remembered in the cache directory, for now of
the vendor dresscode alone, and PackageDiscovery finds those installed beside DressCode by their type dresscode-plugin,
not only those of the project. A project requiring one of their libraries itself gets a missing package installed
first, through Upgrade\Composer: composer require into the installation of DressCode, or require --dev into the project
where its lock holds DressCode, in a terminal after asking and without one only with --yes, otherwise it refuses with
the command to run. The command then starts again in a new process, as the program of this package, whose autoloader
knows the installed packages and the plugins they bring.
Layer names the library, the section and the version `from` says its fixes are right from, and a rule reporting an
entry passes it on to Violation::$source, which the format json writes as `source`; ForbiddenSymbolsRule and
ForbiddenMembersRule tell an entry of a package by it, and an entry of an attribute map names its origin as
MemberMapEntry does. A package of upgrading data named in only stands for the requirement laying its data too.
A string naming a class the maps rename or forbid and a place reached by a computed name, which an upgrade cannot see.

UnseenReferencesRule reports them under upgrading.unseen.* with the word `reported` and fixes nothing.
…nging nothing

Upgrade\Estimator measures it by one check over the sets of the upgrade and the upgrading data, in workers, the targets of
the command line, PHP and packages, laid over those of the configuration before the data are cut, so that the sections of
versions the project has not moved to yet count as reached, and the names resolved for certain where the configuration
says nothing of them. Every place is told by its topic, the library of its entry or its decision, by the bridge or the
jump, and by what a run can do about it; the estimate goes topic by topic with what works today and what after the raise,
as text or as a JSON document. It counts what an upgrade cannot see (BlindSpots): what UnseenReferencesRule reports, the
files no rule reads by their extension and the generated files it leaves out. It names the package of upgrading data a
library of the project has and DressCode lacks; --offline takes the catalogue of Packagist as the cache remembers it.
…uld move

What of the lock refuses each target, and which packages raising the constraints would move across a major version.

The lock is asked as composer why-not --locked answers offline, the moves as the dry run of composer require says, which
--offline leaves out.
… for the team to edit

The steps are what a human has to make way for a target, the bridge, the raise of Composer, the jump, the cleanup, the
modernizations left out and, for an application, the update to the newest versions the constraints allow, written into
dresscode-upgrade.neon with the commands of the tests, each writing a JUnit report; a library, told by its type and its
lock, asked about in a terminal, or named by --library and --no-library, widens its constraints to the targets instead
and its plan is the bridge alone.
…m back

upgrade abort takes back what an unfinished run changed.

A run makes them topic by topic in the style the code keeps (Upgrade\PlanRunner), a step whose tests newly fail going back
byte for byte, and tells where it stopped. Whether a step is done is read from the code and the lock alone, so a run goes
on from a working tree a team committed, squashed or rebased. It waits at a step of Composer with the command to run,
runs the whole suite after the jump and the last step, and warns of a working tree with changes of its own. A step
resolves the names with what the namespaces of the scope declare, as the estimate does. What a run keeps lies in the
directory of the git repository of the project (Upgrade\RunState), from which upgrade abort restores the files of an
interrupted step. A run is written as text or as a JSON document with how far it got.
…w process

It shows what the update changes first, and goes on with the jump in the new process.

In a terminal after asking or with --yes. A raise no fix follows is tested after it all the same, and upgrade abort
restores the composer.json and the lock a raise changed too.
A dresscode:ignore with its reason confirms one.
… human

In the code as it stands, and the suppressions about an upgrade giving no reason, exiting with 1 where one is left.
… the workers included

`--profile <file>` writes as JSON where the time of a run went, in nanoseconds: the phases (the start of the process,
the start of the container of PHPStan or of a derived one and its scopes, the parse, the passes, the gaps, the print,
the cycle collection, the run as a whole), every rule by the time of its callbacks, how often they ran and how often
they changed the tree, every gap rule by the time of its claims, every file with its size, rounds and passes, and the
peak memory of every process. A worker measures its own files and hands that over with every result and the parent
adds it up, so the totals do not depend on the number of workers. A run with the option leaves the result cache out,
a file served from the cache having nothing to measure.

It says where the time of a run goes, not only how much of it: a comparison of two revisions then names the rule or
the claim that moved. Without the option the engine only asks whether there is a profiler, and wraps a claim in
a measurement only when there is one.
…voke(), the rule renamed to invokeCallNotation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AryXexSEtuxAHLeD4fZfQ8
@dg

dg commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks for this, David. The implementation is careful: the rewrite held up on every edge case I threw at it, and the IDE problem you describe is a fair one to want solved. Still, I've decided not to take required into the core.

  • It can't keep the promise. Only what the types prove gets rewritten, so a callable, a mixed or a nullable handler stays $handler($x), and array_map($handler, $items) is no call at all. A search for __invoke() would then look complete without being so.
  • The notation would stop following from the text. The same $x($a) would be right or wrong depending on what PHPStan infers, so changing Handler to callable in a signature would change the correct style of lines elsewhere.
  • The cost is out of proportion. Without typeAnalysis, required does nothing and check still says OK. Refusing that properly means teaching the core about decisions whose values need the types, for one key that no standard would use.

It fits well as a rule of your own project, though, where typesRequired also settles the run without types. I tried this on the current master: take callThroughInvoke() from your PR into its own rule class, with

#[RuleInfo(Stage::Structure, typesRequired: true, analyses: [Types::class])]
final class InvokeCallRequiredRule extends NodeRule

declaring new Decision('project.invokeCall', Domain::state('required'), '...') and reading the types with $context->getAnalysis(Types::class). Then in dresscode.neon:

typeAnalysis: phpstan

rules:
	- Acme\Style\InvokeCallRequiredRule

hygiene:
	__invokeCall: keep

project:
	invokeCall: required

It produced the same output as your PR. The keep is needed wherever a preset such as dresscode/nette sets forbidden, otherwise the two rules undo each other and the fix never settles.

So I'm closing this one, with thanks for the work and for the clear description.

@dg dg closed this Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants