diff --git a/AGENTS.md b/AGENTS.md index 3d9d5639ba..9b27eeaf3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,15 +5,15 @@ It is my fervent wish that this file guide every AI coding agent working with co ## Documentation -`docs/internals.md` is the source of truth for how DressCode works: the tree as a rule sees it, the mutation API, the engine of gaps and the rules. Read it before any non-trivial change. `docs/dresscode.annotated.neon` is a configuration setting every decision, each with what its value means and the other values it takes, the value that of PER Coding Style where its preset decides and the best one where it says nothing: a decision added, renamed or given other values is written there too, which `tests/DressCode/Config/annotated.phpt` checks. +`docs/internals.md` is the source of truth for how DressCode works: the tree as a rule sees it, the mutation API, the engine of gaps, the rules and the interop. Read it before any non-trivial change. `docs/skills/writing-rules.md` is the procedure of writing a rule or porting one from another tool, the one an agent outside this repository follows too. `docs/dresscode.annotated.neon` is a configuration setting every decision, each with what its value means and the other values it takes, the value that of PER Coding Style where its preset decides and the best one where it says nothing: a decision added, renamed or given other values is written there too, which `tests/DressCode/Config/annotated.phpt` checks. ## Project overview DressCode is a PHP code style checker and fixer built on a **lossless concrete syntax tree**: every token of the source is in the tree, whitespace and comments are trivia attached to tokens, and printing the tree reproduces the input byte for byte. -The tree itself is the `phpsyntax/phpsyntax` library (namespace `PhpSyntax`), developed in a repository of its own. This one holds `DressCode` (`src/`): engine, rules API, the library of generic rules, configuration, CLI. +The tree itself is the `phpsyntax/phpsyntax` library (namespace `PhpSyntax`), developed in a repository of its own. This one holds `DressCode` (`src/`): engine, rules API, the library of generic rules, configuration, CLI. The names of other tools live only in `Interop/`, never in a rule. -Rules use only the public API of `PhpSyntax`; whatever a rule in DressCode needs from it is public API for plugins too. What the rules only share among themselves (`Rules\NodeHelpers`, `Rules\Compiler` and the helpers beside the rules that carry `@internal`) is `@internal`; the grammar of a map of members (`Upgrading\MemberMapGrammar`, the map itself `MemberMap` with `findAccess()`, its `MemberMapEntry` and `MemberMapMatch`, `MemberPattern`, `ArgumentPattern`, `FunctionPattern`) is not, a rule shipped by a package being fed the same way, and neither is `GlobalCalls`, which answers which global function a call calls and why that may be another one, nor `CodeWriter`, which writes code in the shape of the file (a class written the way the file reaches it, an import added the way the file writes its imports) for a rule of a package as for one of the core. Presets define style; DressCode has no style of its own. The three standards carry one: `dresscode/perCs` (PER Coding Style 3.1), `dresscode/psr12` and `dresscode/nette` (the Nette Coding Standard, PER with tabs, the layout of the Nette libraries and their departures, which the repository itself is checked with). What a project wants beyond the looks of its code comes in a set, a preset of one intent that decides nothing about the layout and is used beside the standard (`modernizations`, `deprecations`, `cleanup`, `correctness`, `types`, `compilerOptimizations`); a decision the core gains goes into its set, one that writes a newer construct and the replacement of what PHP deprecated alike into every set whose intent it meets (`upgrading.syntax.firstClassCallables` in `modernizations` and `deprecations`), and what no set holds the project decides itself. +Rules use only the public API of `PhpSyntax`; whatever a rule in DressCode needs from it is public API for plugins too. What the rules only share among themselves (`Rules\NodeHelpers`, `Rules\Compiler` and the helpers beside the rules that carry `@internal`) is `@internal`; the grammar of a map of members (`Upgrading\MemberMapGrammar`, the map itself `MemberMap` with `findAccess()`, its `MemberMapEntry` and `MemberMapMatch`, `MemberPattern`, `ArgumentPattern`, `FunctionPattern`) is not, a rule shipped by a package being fed the same way, and neither is `GlobalCalls`, which answers which global function a call calls and why that may be another one, nor `CodeWriter`, which writes code in the shape of the file (a class written the way the file reaches it, an import added the way the file writes its imports) for a rule of a package as for one of the core. Presets define style; DressCode has no style of its own. The five standards carry one: `dresscode/perCs` (PER Coding Style 3.1), `dresscode/psr12`, `dresscode/nette` (the Nette Coding Standard, PER with tabs, the layout of the Nette libraries and their departures, which the repository itself is checked with), `dresscode/symfony` and `dresscode/laravel` (the preset laravel of Laravel Pint). What a project wants beyond the looks of its code comes in a set, a preset of one intent that decides nothing about the layout and is used beside the standard (`modernizations`, `deprecations`, `cleanup`, `correctness`, `types`, `compilerOptimizations`); a decision the core gains goes into its set, one that writes a newer construct and the replacement of what PHP deprecated alike into every set whose intent it meets (`upgrading.syntax.firstClassCallables` in `modernizations` and `deprecations`), and what no set holds the project decides itself. ## Essential commands @@ -22,6 +22,7 @@ Rules use only the public API of `PhpSyntax`; whatever a rule in DressCode needs - `composer reference`: regenerates `docs/reference/decisions.md` (every decision with its values, the values of the standards and the rules declaring it) from the catalogue. Commit the output; CI diffs it. - `composer api`: regenerates `docs/reference/api.md`, the public classes and members as reflection sees them. Commit the output and read its diff, which is what a change does to the public surface; CI diffs it. - `php bin/dresscode check`: DressCode over its own sources with `dresscode.neon`; CI runs it too. +- `php bin/dresscode init` in the root of another project: writes the `dresscode.neon` measured from its code, never over a configuration that exists; how it measures is in `docs/internals.md`. ## Conventions @@ -58,7 +59,7 @@ Rules use only the public API of `PhpSyntax`; whatever a rule in DressCode needs - A qualification writes a name imported or qualified only where that has an effect, so `function.optimized` takes a function only in a namespace where PHP optimizes one of its calls, the other functions there being decided by `function.global`. - Comments only where the code itself is not enough; never restate what the code shows; density follows the surrounding file. No phpDoc for what the types already say. - Code in a doc comment is written in backticks: a call, a variable or a property, an operator, a keyword, a literal, the case of an enum (`report()`, `$this`, `?->`, `static`, `'*'`, `Severity::Warning`). Doc comments are read as Markdown in an IDE, and what stands bare there is typeset as text: `->` becomes an arrow, `...` an ellipsis. Backticks mark the code itself, so what only resembles code stays bare, whatever the IDE makes of it: a keyword used as a word (a static method, a readonly class, but a `match` expression), `null`, `true` and `false` in "null when …", and the `=>` describing a map (path => its size). -- A framework or library is named in the core only where it is the subject of the code (its preset, a dependency, a file or key another tool defines). An example of a general mechanism uses invented names under the vendor `Acme`, in a domain of their own that mirrors no real API (`Acme\Shop\Order::STATUS_PAID`, `Acme\Mail\SmtpTransport`). +- A framework or library is named in the core only where it is the subject of the code (its preset, its interop, a dependency, a file or key another tool defines). An example of a general mechanism uses invented names under the vendor `Acme`, in a domain of their own that mirrors no real API (`Acme\Shop\Order::STATUS_PAID`, `Acme\Mail\SmtpTransport`). - Code, comments, identifiers and messages in English. - A string a `.neon` file has to quote is written in single quotes (`call: 'foo($a, $b)'`), double quotes only where it needs an escape or holds a single quote. diff --git a/composer.json b/composer.json index 436ef21c9f..f936d93247 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "dresscode/dresscode", - "description": "👔 DressCode: checks and fixes PHP coding style, and upgrades code to newer PHP and to new versions of libraries. Built on a lossless syntax tree, so it changes only what it touches. PER Coding Style, PSR-12, Nette.", + "description": "👔 DressCode: checks and fixes PHP coding style, and upgrades code to newer PHP and to new versions of libraries. Built on a lossless syntax tree, so it changes only what it touches. PER Coding Style, PSR-12, Nette, Symfony and Laravel.", "keywords": ["code style", "coding standard", "formatter", "fixer", "linter", "refactoring", "modernization", "upgrade", "deprecations", "psr-12", "per-cs", "php-cs-fixer", "phpcs", "rector"], "homepage": "https://dresscode.run", "license": "MIT", @@ -13,7 +13,7 @@ "require": { "php": "8.4 - 8.6", "nette/command-line": "^2.0", - "nette/neon": "^3.4 || ^4.0", + "nette/neon": "^4.0", "nette/schema": "^1.4 || ^2.0", "nette/utils": "^4.1", "phpsyntax/phpsyntax": "^1.0", @@ -50,7 +50,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.3-dev" + "dev-master": "1.6-dev" } } } diff --git a/docs/dresscode.annotated.neon b/docs/dresscode.annotated.neon index 1a1f105e1f..a5c5b97a78 100644 --- a/docs/dresscode.annotated.neon +++ b/docs/dresscode.annotated.neon @@ -46,7 +46,7 @@ # # The comment of a key says what its value means, then after "Also:" the other values it takes. -use: [perCs] # the conventions of PER Coding Style 3.1, which this file writes out. Also: psr12; nette; a list with sets: cleanup, correctness, modernizations, deprecations, types, compilerOptimizations +use: [perCs] # the conventions of PER Coding Style 3.1, which this file writes out. Also: psr12; nette; symfony; laravel; a list with sets: cleanup, correctness, modernizations, deprecations, types, compilerOptimizations targets: php: '8.2' # the oldest version the code runs on; quoted, NEON reads `8.10` as 8.1 @@ -472,7 +472,7 @@ hygiene: # what is there for nothing: a call settype: forbidden # `settype($x, 'int');` is `$x = (int) $x;`. Also: keep call_user_func: forbidden # `call_user_func($f, $x)` is `$f($x)`, `call_user_func_array()` alike; risky. Also: keep call_user_funcMethods: no # parameter: also a method the callable names, `[$this, 'run']`. Also: yes - __invokeCall: forbidden # `$f->__invoke($x)` is `$f($x)`. Also: keep + __invokeCall: forbidden # `$f->__invoke($x)` is `$f($x)`, or the other way round for an object. Also: required; keep dirnameOfFile: forbidden # `dirname(__FILE__)` is `__DIR__`. Also: keep nestedDirname: forbidden # `dirname(dirname($p))` is `dirname($p, 2)`. Also: keep strlenEmptyTest: forbidden # `strlen($s) === 0` is `$s === ''`; risky, with the types. Also: keep @@ -580,3 +580,6 @@ upgrading: # what a newer PHP writes or retired forbiddenMembers: {} # a member that may not be used attributeForAnnotation: {} # an attribute written for an annotation; a namespace mask with `\*` attributeForMember: {} # an attribute written for a member the class declares by convention + unseen: # what an upgrade cannot see, reported where it stands + classNameString: reported # the string `'Acme\Old'` naming a class the maps rename or forbid. Also: keep + dynamicAccess: reported # `$object->$method()`, `new $class`, `call_user_func($callback)`. Also: keep diff --git a/docs/internals.md b/docs/internals.md index f92a2522e4..4b0003d6d7 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -25,7 +25,7 @@ Public: Internal: -- `Engine\*`, `Config\*`, `Console\*`: the engine, the configuration and the console +- `Engine\*`, `Interop\*`, `Config\*`, `Measuring\*`, `Upgrade\*`, `Console\*`: the engine, the translation of foreign configurations, the rest of the configuration, the measuring of `init`, the estimate, the plan and the run of `upgrade` and the console - `Reporter`, `Reporters\*`: the formats of the output, whose contract is the output itself, and the interface they implement next to them; they print the `Engine\RunResult` of a run - `Rules\NodeHelpers`, `Rules\ListLayout`, `Rules\Compiler`, `Rules\NativeType`, `Rules\Classes\PropertyWrites`, `Rules\Classes\MemberModifiers`, `Rules\Functions\ForwardingClosure`, `Rules\Namespaces\NameReferences`, `Rules\Namespaces\QualificationPolicy`, `Rules\PhpDoc\AnnotationReplacement`, `Rules\PhpDoc\PhpdocTypeNotationVisitor`, `Rules\Whitespace\BlankLineClaims`, `Rules\Whitespace\Padding`: what the rules share among themselves or a rule does in a class of its own - `Rules\Upgrading\AnnotationArguments`, `Rules\Upgrading\CallableLiteral`, `Rules\Upgrading\CallTemplate`, `Rules\Upgrading\ClassReplacement`, `Rules\Upgrading\MagicCall`, `Rules\Upgrading\MemberTarget`, `Rules\Upgrading\AttributeTarget`, `Rules\Upgrading\Rewrite`, `Rules\Upgrading\AttributeForMemberEntry`, `Rules\Upgrading\AnnotationMap`, `Rules\Upgrading\enums.php`: the helpers of the rules fed by maps that are not the grammar of the maps @@ -139,7 +139,7 @@ A gap rule is made of claims alone: `GapRule` and `NodeRule` are the two shapes ### Identity -A rule is one instance per run and stateless across files; the state of a pass over a file lives in `RuleContext::$passStorage`, which every pass begins empty, so that the last pass reports all it finds. It has no name: what a user writes, reads in a report, silences and accepts is a decision, and the rule is known by its class, in the profile, in a fixture and in the message of a broken contract. Every rule declares or names a decision at least, and one that has none is a mistake of the configuration, refused by the catalogue and by the plan. It reports an occurrence under the path of the decision it violates (the `decision` of `report()`, `Claim::$decision`), which only a rule of one requirement leaves out; a report under a decision the rule does not declare or under a parameter is a broken contract, and one under a requirement that is `keep` or narrowed away records nothing and returns false. What decides it is the `Engine\Gate` of the rule, which the plan holds for every rule, made from the values of the run (`ResolvedConfig::getGates()`) or open, every requirement reported, for a rule run without a resolution; the requirements it admits are also those among which a narrowed run counts the entries of the baseline nothing matched. `PluginRegistry` keeps the classes in the order of their registration and the presets by name. +A rule is one instance per run and stateless across files; the state of a pass over a file lives in `RuleContext::$passStorage`, which every pass begins empty, so that the last pass reports all it finds. It has no name: what a user writes, reads in a report, silences and accepts is a decision, and the rule is known by its class, in the profile, in a fixture and in the message of a broken contract. Every rule declares or names a decision at least, and one that has none is a mistake of the configuration, refused by the catalogue and by the plan. It reports an occurrence under the path of the decision it violates (the `decision` of `report()`, `Claim::$decision`), which only a rule of one requirement leaves out; a report under a decision the rule does not declare or under a parameter is a broken contract, and one under a requirement that is `keep` or narrowed away records nothing and returns false. What decides it is the `Engine\Gate` of the rule, which the plan holds for every rule, made from the values of the run (`ResolvedConfig::getGates()`) or open, every requirement reported, for a rule run without a resolution; the requirements it admits are also those among which a narrowed run counts the entries of the baseline nothing matched. `PluginRegistry` keeps the classes in the order of their registration and the presets by name. What a rule of another tool means here is not part of that identity: it lives in `DressCode\Interop`, where a translation writes decisions. ### Report before mutation @@ -189,11 +189,11 @@ A violation also carries the span of what it is about, `Violation::$offset` and `--lines` narrows what a run reports to those lines of the file as read (`Engine\LineRanges`, over the result of the file before the reporter and the summary see it): a violation is listed where its original lines reach into a range or where it has none, the rest is counted in `FileResult::$unlisted` and decides no exit code. The rules, the suppressions and the cache see the whole file, so a fix still fixes all of it. It takes one file, the `agent` format and no `--strict-rules`. -`--before ` gives the text the file had before the edit, and then what the file did not follow is its own style rather than the edit's mistake: the decisions that text violates, found by processing it first (`CheckCommand::findBrokenDecisions()`), are left as the file writes them (`ReportPolicy::$leftAsWritten`: `report()` answers false and the pass loop holds it like a refused fix), and every other decision is fixed in the whole file, because a violation of a decision the file kept is one the edit made, wherever it stands: an import whose last use it removed, the lines it put into a new block. The listing follows (`LineRanges` with the decisions left): a violation of a kept decision is listed wherever it is, one of a decision left only where it waits for a judgement, a risky fix or none, on the lines of `--lines`, and the agent is told the decisions left whose violations have a plain fix, which are the style of the file to write in. A file whose old text breaks a decision anywhere thus has it left everywhere, which keeps the file consistent: an edit of a file indented by spaces stays indented by spaces. +`--before ` gives the text the file had before the edit, and then what the file did not follow is its own style rather than the edit's mistake: the decisions that text violates, found by processing it first (`Console\RunExecutor::findBrokenDecisions()`), are left as the file writes them (`ReportPolicy::$leftAsWritten`: `report()` answers false and the pass loop holds it like a refused fix), and every other decision is fixed in the whole file, because a violation of a decision the file kept is one the edit made, wherever it stands: an import whose last use it removed, the lines it put into a new block. The listing follows (`LineRanges` with the decisions left): a violation of a kept decision is listed wherever it is, one of a decision left only where it waits for a judgement, a risky fix or none, on the lines of `--lines`, and the agent is told the decisions left whose violations have a plain fix, which are the style of the file to write in. A file whose old text breaks a decision anywhere thus has it left everywhere, which keeps the file consistent: an edit of a file indented by spaces stays indented by spaces. ### Baseline -A `Baseline` (per file and fingerprint, written by `dresscode baseline` into the file the key `baseline` names, or into `dresscode-baseline.*` beside the configuration, which the user then has to name for it to apply) is asked at `report()` beside the `dresscode:ignore` comment, and it is no suppression: a violation the baseline holds is not recorded, but `report()` does not deny it, so a rule that can fix it by now fixes it and the entry turns stale; outside the baseline **`fix` changes exactly what `check` reports**. +A `Baseline` (per file and fingerprint, written by `dresscode baseline` into the file the key `baseline` names, which a run then requires, or into `dresscode-baseline.*` in the root, the extension that of the configuration, which a run finds by itself and the header of `check` names) is asked at `report()` beside the `dresscode:ignore` comment, and it is no suppression: a violation the baseline holds is not recorded, but `report()` does not deny it, so a rule that can fix it by now fixes it and the entry turns stale; outside the baseline **`fix` changes exactly what `check` reports**. A baseline is the way to enforce a rule the code does not meet yet, and it holds only what a fix leaves: `dresscode baseline`, a command taking the options of `check` that make sense for it (not `--only`, `--fix-risky`, `--stdin`, `--diff` or `--max-warnings`, which it does not know), writes nothing while a fix would still change a file or a file fails or does not parse (exit code 1), so its entries are the violations the rules have no fix for and the risky fixes the project does not accept that change what the code does, the others being warnings that wait for the types or a certain resolution; a style the rules can fix is reached by a fix, not recorded. A later round of `PhpFileProcessor` computes the fingerprints from its own text, so a baselined violation on a line another fix rewrote is not recognized there: it is recorded in `remaining`, which is what the next check reports. @@ -201,7 +201,7 @@ Its format follows the extension of its file, so it is written in whichever of t ### Suppression -Suppression is read once from the original comments before any mutation: `dresscode:ignore [paths]` on a line silences that line, a section or a structure standing for every decision under it, on its own line the nearest node starting on the next line, `disable`/`enable` a range, `ignoreFile` everything; what follows ` -- ` says why, as in ESLint and phpcs, and names no rule. A name no decision stands for is a warning of the run. A project that already marks its intended exceptions with a comment of its own says so in `suppressionComments`, a map of a pattern to the decisions it silences (`'~\bintentionally\s+[!=]=~': expressions.comparison.loose`, which the `nette` preset carries): a comment the pattern matches silences those decisions as `dresscode:ignore` would at its place, on its line or, alone on a line, on the next node; an entry written as `{decisions: …, example: '// @ '}` carries the example of such a comment too, which must match the pattern with a word put for ``, and a layer saying the pattern again replaces the entry, the example with it (`ResolvedConfig::$suppressionExamples`, what the output for an agent offers); a pattern is matched against the text of one comment, so `^` stands at its start (`'~^//\s*@\s+\S~'`), and where a configuration has any, every file has its comments walked. +Suppression is read once from the original comments before any mutation: `dresscode:ignore [paths]` on a line silences that line, a section or a structure standing for every decision under it, on its own line the nearest node starting on the next line, `disable`/`enable` a range, `ignoreFile` everything; what follows ` -- ` says why, as in ESLint and phpcs, and names no rule. The forms of PHP_CodeSniffer (`phpcs:ignore`, `phpcs:disable`, `phpcs:enable`, `phpcs:ignoreFile` and the `@phpcsSuppress` annotation) name the rules of another tool, which `Interop\Translator` translates, one name possibly standing for several decisions, or the decisions of DressCode by their paths; a name no decision stands for is a warning of the run. `dresscode migrate suppressions` rewrites them to the dresscode form with the paths, the annotation keeping its tag, which has no form of ours, without building the rules, which may not run yet; it rewrites only a directive that ends its comment or its line, as the comments are read, so prose naming one stays as it is, and counts what the files it wrote hold (`SuppressionRewrite`); a name no decision covers and a file it skips, one that does not parse or changed meanwhile, are listed where they stand and make its exit code 2, an ignore on a line of its own, whose scope grows to the whole next statement, is listed by its line with exit code 1; with `--dry-run` it shows the rewrites as a diff and writes nothing. A project that already marks its intended exceptions with a comment of its own says so in `suppressionComments`, a map of a pattern to the decisions it silences (`'~\bintentionally\s+[!=]=~': expressions.comparison.loose`, which the `nette` preset carries): a comment the pattern matches silences those decisions as `dresscode:ignore` would at its place, on its line or, alone on a line, on the next node; an entry written as `{decisions: …, example: '// @ '}` carries the example of such a comment too, which must match the pattern with a word put for ``, and a layer saying the pattern again replaces the entry, the example with it (`ResolvedConfig::$suppressionExamples`, what the output for an agent offers); a pattern is matched against the text of one comment, so `^` stands at its start (`'~^//\s*@\s+\S~'`), and where a configuration has any, every file has its comments walked. ### Processing a file @@ -211,21 +211,23 @@ The violations reported are those of the first round, whose positions belong to ### Running -`Runner::findFiles()` finds the files (patterns with a slash are anchored to the root, without one they match a segment at any depth, both also match directories on the way, and `**/` is no directory or any number of them, as in gitignore, a pattern that is no valid glob being refused when the configuration is read; a file named on the command line is taken as it is, and only `--skip-excluded`, which a hook or an editor naming every file it touches passes, lets the excluded paths leave it out like a found one, and the configured `paths` leave out what they do not hold; the excluded paths are those of the configuration and its plugins over `Config::DefaultExcludePaths`, and a `.gitignore` is not read, what it ignores, a build or a cache, being left out by `excludePaths` where the defaults miss it) and `Runner::run()` takes that list, reads and writes the files and hands results to a `Reporter`. The caller expands the paths so that it knows the scope before the run starts. A path named on the command line is relative to the working directory, and `Runner::narrowPaths()` narrows a named directory that holds some of the configured `paths` to those, so that `fix .` in the root never reaches beyond them, while a path outside them is taken as named, unless `--skip-excluded` says the caller names every file it touches. +`FileFinder::findFiles()`, which a run holds as `Runner::$files` and `RunnerFactory::createFileFinder()` gives without building a run, finds the files (patterns with a slash are anchored to the root, without one they match a segment at any depth, both also match directories on the way, and `**/` is no directory or any number of them, as in gitignore, a pattern that is no valid glob being refused when the configuration is read; a file named on the command line is taken as it is, and only `--skip-excluded`, which a hook or an editor naming every file it touches passes, lets the excluded paths leave it out like a found one, and the configured `paths` leave out what they do not hold; the excluded paths are those of the configuration and its plugins over `Config::DefaultExcludePaths`, and a `.gitignore` is not read, what it ignores, a build or a cache, being left out by `excludePaths` where the defaults miss it) and `Runner::run()` takes that list, reads and writes the files and hands results to a `Reporter`. The caller expands the paths so that it knows the scope before the run starts. A path named on the command line is relative to the working directory, and `FileFinder::narrowPaths()` narrows a named directory that holds some of the configured `paths` to those, so that `fix .` in the root never reaches beyond them, while a path outside them is taken as named, unless `--skip-excluded` says the caller names every file it touches. -The format is the whole contract of the output, never a switch modifying another one: `console` unless `--format` says otherwise, `github` when `GITHUB_ACTIONS` says the run is a step of a workflow, `json` and `checkstyle` for tools (the JSON document says its `version`, the `mode` and the `root` of the run, its keys are camelCase and the values from the code stand as they are, the `severity` of a violation being `error` or `warning` and its `risk` `typeUnknown`, `nameUncertain` or `behaviorChanges`, the backing values of `Severity` and `Risk`, the span of a violation as `offset` and `length` and where it ends as `endLine` and `endColumn`, null where it has none, a key removed, renamed or meaning another thing being a new version and a key added none), `lines` for a hook of git and the linters of an editor, one line for every event in the shape a compiler writes (`path:line:column: severity: message [decision; risk=…]`, the column where the span starts when it starts on that line, the path relative to the working directory or absolute and with forward slashes, a message on one line, a clean or merely rewritten file printing nothing), `agent` for an AI agent that has just edited the files (`Reporters\AgentReporter`: sentences on stdout naming the file as the command line named it, what a fix wrote as the hunks of a fenced diff with their standard headers, the imports it removed by name from `FileResult::$removedImports`, and every violation left with what to do about it, the reason of a risky fix it did not make and the comment the configuration of the file accepts from `suppressionComments`, never `dresscode:ignore`; a clean file says nothing, a rewrite is claimed only where the file was written, the warnings of the configuration are left out by the command, and the whole message keeps to a budget of lines and bytes, a diff that does not fit giving way to a summary naming the decisions of the rules that changed the file, `FileResult::$mutatedRules`, and a listing that does not fit saying how many it left out). The header naming the configuration file and the scope belongs to the command (`Console\Commands\CheckCommand`) and is written, together with the warning that Xdebug makes the run many times slower, only for the console and github formats; the progress bar only for the console format on a terminal, where it is a status of the very console the report goes to, so that writing the report erases it. The console format lists twenty violations of a file and counts the rest by decision on one line, the formats for tools listing every one. A fix run lists every file it touched exactly once, with a `rewritten` note and under it what the fixed text still violates; which violation a fix removed cannot be told from the text, so the verdict counts what was found and what remains, and `--diff` shows what changed. +The format is the whole contract of the output, never a switch modifying another one: `console` unless `--format` says otherwise, `github` when `GITHUB_ACTIONS` says the run is a step of a workflow, `json` and `checkstyle` for tools (the JSON document says its `version`, the `mode` and the `root` of the run, its keys are camelCase and the values from the code stand as they are, the `severity` of a violation being `error` or `warning` and its `risk` `typeUnknown`, `nameUncertain` or `behaviorChanges`, the backing values of `Severity` and `Risk`, the span of a violation as `offset` and `length` and where it ends as `endLine` and `endColumn`, null where it has none, a key removed, renamed or meaning another thing being a new version and a key added none), `lines` for a hook of git and the linters of an editor, one line for every event in the shape a compiler writes (`path:line:column: severity: message [decision; risk=…]`, the column where the span starts when it starts on that line, the path relative to the working directory or absolute and with forward slashes, a message on one line, a clean or merely rewritten file printing nothing), `agent` for an AI agent that has just edited the files (`Reporters\AgentReporter`: sentences on stdout naming the file as the command line named it, what a fix wrote as the hunks of a fenced diff with their standard headers, the imports it removed by name from `FileResult::$removedImports`, and every violation left with what to do about it, the reason of a risky fix it did not make and the comment the configuration of the file accepts from `suppressionComments`, never `dresscode:ignore`; a clean file says nothing, a rewrite is claimed only where the file was written, the warnings of the configuration are left out by the command, and the whole message keeps to a budget of lines and bytes, a diff that does not fit giving way to a summary naming the decisions of the rules that changed the file, `FileResult::$mutatedRules`, and a listing that does not fit saying how many it left out). The header naming the configuration file and the scope belongs to the run of the command (`Console\RunExecutor`, which `check`, `fix`, `baseline` and `upgrade` share) and is written, together with the warning that Xdebug makes the run many times slower, only for the console and github formats; the progress bar only for the console format on a terminal, where it is a status of the very console the report goes to, so that writing the report erases it. The console format lists twenty violations of a file and counts the rest by decision on one line, the formats for tools listing every one. A fix run lists every file it touched exactly once, with a `rewritten` note and under it what the fixed text still violates; which violation a fix removed cannot be told from the text, so the verdict counts what was found and what remains, and `--diff` shows what changed. `fix --stdin` is the formatter of an editor, which reads the text and the exit code alone: the whole fixed text goes to stdout and nothing to stderr, with exit code 0 whatever the text still violates, an empty input being valid; a syntax error gives the text back as it came with one line on stderr and exit code 1, a failure the same with exit code 2, and a path `--skip-excluded` leaves out the text as it came without a word. It writes nothing to the disk and refuses the options of a report, `--format`, `--diff`, `--max-warnings` and `--ask-risky`, with the advice of `check --stdin`, which reports on the code in any format, and nothing at all of a path `--skip-excluded` leaves out; the warnings of the configuration are not printed. -A rule exception or a cycle fails that one file (nothing is written, the run goes on, exit code 2, also for a runtime error of the tool), and so does a file that cannot be read or written, and a file whose text on the disk is no longer the one processed when the fix is about to write it, so that what an editor saved meanwhile is never overwritten; a syntax error is exit code 1 like a violation, and so is a baseline refused, while a mistake of the command line or of the configuration, and a program run without a command, is exit code 3, which is no result of a run at all. The fix writes a file through a temporary one renamed over it, so that an interrupted run never leaves it cut short, and in place where no temporary file can be made beside it or renamed over it (a directory that is not writable, a file mounted on its own, on Windows a file another process holds). +A rule exception or a cycle fails that one file (nothing is written, the run goes on, exit code 2, also for a runtime error of the tool), and so does a file that cannot be read or written, and a file whose text on the disk is no longer the one processed when the fix is about to write it, so that what an editor saved meanwhile is never overwritten; a syntax error is exit code 1 like a violation, and so is a baseline refused, while a mistake of the command line or of the configuration, and a program run without a command, is exit code 3, which is no result of a run at all; an upgrade waiting at a step outside DressCode, for Composer or for a human, is exit code 4. The fix writes a file through a temporary one renamed over it, so that an interrupted run never leaves it cut short, and in place where no temporary file can be made beside it or renamed over it (a directory that is not writable, a file mounted on its own, on Windows a file another process holds). A `ResultCache` (one JSON file per project root in `cacheDir` or the system temp) remembers every content that came out clean under the effective configuration: the rules and the values of the decisions, the selection of `only`, style, PHP version, the same of every override resolved on its own, so that a preset only an override uses counts too, the installed version of each package of the project with the reference of the source it came from (`ProjectPackages::getIdentity()`), the same of each package of the running process, which is where the tool, its PHPStan and the extensions of it come from wherever they are not installed in the project, its root and the roots inside a phar left out (`RunnerFactory::getProcessIdentity()`), the times and sizes of the files of the rule, preset and analysis classes neither a package of the process nor a phar holds, which a rule of the project itself changes under the same version (a process without Composer knows no package, so every file outside a phar counts), and so of every PHP and NEON file of the source trees of DressCode and PhpSyntax, whose helpers and data the rules stand on, where they are a working tree rather than a package, and the text of the configuration file, hashed by `RunnerFactory::hashConfiguration()`. That text is all that describes a rule or an analysis a closure builds, so such a closure must build from nothing but what the text says, and a run with a closure and no file is not cached. A content is remembered by its path together with its text, because an override or a rule reading the path gives the same text in two files two verdicts; `Runner::run()` skips such a file with a `cached` result, its text read again when it is reported and processed after all where it is no longer the text the cache knew, and, after a fix whose text has nothing remaining, remembers the written content too, unless there is a baseline, whose entries no run has yet held against the fixed lines. With the types a result also depends on what other files of the project declare and on the `phpstan.neon` of the project, so the identity then holds the time and size of every file under the analysed paths, the configuration of PHPStan and `installed.json` (`RunnerFactory::fingerprintProject()`): a change anywhere throws the cache away, which is the price of a clean verdict that stays true after a pull or a rebase. What that configuration includes, its stubs and bootstrap files outside those paths, only the container of PHPStan knows, and a run served whole by the cache builds none, so the run that builds one records them with their stamps in the cache (`PhpStan::getConfigurationFiles()`, a worker sending them with its first result after it knows them, `ResultCache::recordDependencies()`), and the next run drops the cache where any of them changed; those of PHPStan itself and those in its temporary directory are left out. A file modified in the second before the run began or later, where a change in the same second would leave its time and size as they were, adds the hash of its text, as git does with the files it calls racy, which holds for the files of the rules too; a file restored with an older time and the same size is the one limit left. `--no-cache` serves no file from it but remembers what the run finds, forgetting a content it found not clean; `processCode()` (stdin) never uses it. -With `--jobs` above 1 (by default the processor count, capped at one worker per four files, because a worker costs about that much to start; an explicit `--jobs` is taken as given) the files not served by the cache go to a `WorkerPool`. Worker processes are started with the same command line plus a hidden `--worker
`, under the `php.ini` of the parent (`-c`, or `-n` where it loaded none) and with the settings the parent has beyond it, those of `-d` among them, which PHP tells no process, so the parent asks the same PHP for its settings and passes on the difference as `-d`, and connect to a TCP server on the loopback: `stream_select()` handles no pipes on Windows before PHP 8.5, and where it does, a message over a pipe costs there hundreds of times what it costs over the loopback, so the server stays whatever the minimum version. With the types, a worker fills the cache of PHPStan before it connects (`PhpStan::warmUp()`), and the pool starts the others only once the first has connected: workers started together would all write the same cold cache, which on Windows makes every write slow and fails the renames over a file another worker holds. A worker that has not connected within the connect timeout of the pool fails the run, which with the types bounds that warm-up. A worker receives one path at a time and answers with the `FileResult` as a line of JSON (`Engine\WorkerCodec`), the path both ways and the output base64-encoded, since neither need be UTF-8. The parent reads the connections without blocking and puts a line together from its parts, because waiting for the rest of one would stop it from hearing the other workers and from noticing a stuck one; it takes an answer only about the path it gave that worker, and a worker spending longer than the timeout of the pool on one file fails the run, since a rule has most likely looped. The listening socket is closed as soon as the last worker is in, because on Windows closing it later costs every run a wait, and it comes from ext/sockets where there is one, because closing one of the stream layer there waits half a second. +With `--jobs` above 1 (by default the processor count, capped at one worker per four files, because a worker costs about that much to start; an explicit `--jobs` is taken as given) the files not served by the cache go to a `WorkerPool`. Worker processes are started with the same command line plus a hidden `--worker
`, under the `php.ini` of the parent (`-c`, or `-n` where it loaded none) and with the settings the parent has beyond it, those of `-d` among them, which PHP tells no process, so the parent asks the same PHP for its settings and passes on the difference as `-d`, and connect to a TCP server on the loopback: `stream_select()` handles no pipes on Windows before PHP 8.5, and where it does, a message over a pipe costs there hundreds of times what it costs over the loopback, so the server stays whatever the minimum version. With the types, a worker fills the cache of PHPStan before it connects (`PhpStan::warmUp()`), and the pool starts the others only once the first has connected: workers started together would all write the same cold cache, which on Windows makes every write slow and fails the renames over a file another worker holds. A worker that has not connected within the connect timeout of the pool fails the run, which with the types bounds that warm-up. A worker receives one path at a time and answers with the `FileResult` and what it measured for `--profile` as a line of JSON (`Engine\WorkerCodec`), the path both ways and the output base64-encoded, since neither need be UTF-8. The parent reads the connections without blocking and puts a line together from its parts, because waiting for the rest of one would stop it from hearing the other workers and from noticing a stuck one; it takes an answer only about the path it gave that worker, and a worker spending longer than the timeout of the pool on one file fails the run, since a rule has most likely looped. The listening socket is closed as soon as the last worker is in, because on Windows closing it later costs every run a wait, and it comes from ext/sockets where there is one, because closing one of the stream layer there waits half a second. A process working through files, the parent without workers and every worker, keeps the cycle collector of PHP off and collects between two files once the memory has doubled since what the last collection left, never above three quarters of `memory_limit` (`Engine\CycleCollector`). The collector PHP runs by itself scans again and again what stays alive, the tree being processed and above all the reflection of PHPStan, and frees little: with the types it took a third of the run. -A worker only processes and, when fixing, writes; the parent keeps the cache and the baseline, reads a file again when it hands it out, reports every file in the order of the input as soon as the files before it are done and keeps of its result what the run needs without the code and the output (`Engine\FileSummary`), so that only the files in progress and those waiting for an earlier one are held as text, and turns a worker that dies or exits with an error into a failure of the run. +A worker only processes and, when fixing, writes; the parent keeps the cache and the baseline, reads a file again when it hands it out, reports every file in the order of the input as soon as the files before it are done and keeps of its result what the run needs without the code and the output and with the violations only counted (`Engine\FileSummary`, `Engine\ViolationTally`), so that only the files in progress and those waiting for an earlier one are held whole, a run whose violations code consumes one by one taking them through `Reporters\CollectingReporter`, and turns a worker that dies or exits with an error into a failure of the run. + +The hidden option `--profile ` of `check`, `fix`, `baseline` and `upgrade today` writes where the time of the run went as JSON (`Engine\Profiler`): the phases of processing a file (parse, passes, print, each round counted, and the gaps the engine decides along the traversal, the claims of the rules included), the callbacks of every rule with the calls that changed the tree, the time of the closures of every gap rule's claims, the start of PHPStan, its derived containers and scopes, the collections of cycles, the fifty slowest files with the phases of each, the size and the time of every file, and the peak memory of every process. A worker hands over what it measured with every file, beside its result, and, once nothing is left, what it measured since in an answer to `finish`, which the pool waits for from every worker, one that started too late to get a file among them; the parent adds it up, so the counts do not depend on `--jobs`; the result cache is off, since a file it serves has nothing to measure. Off, the profiler costs a null check around each callback of a rule; on, it costs the clock read twice around each, so its times say where the time goes and the times of a run without it how much there is. ## Configuration @@ -239,13 +241,13 @@ The keys at the top are either reserved or sections (`Config\Catalogue::Reserved What the PHP notation passes as an object or a callable the NEON writes as an entity in three places only, a plugin in `use`, `skipWhen` and the factory of an analysis: `Class(args)` is a new object, `Class::method(args)` what the static method returns, `Class::method(...)` the method itself, and in a chain `Class(args)::method(args)` a link calls the method on what the link before it gave. An entity is evaluated when the file is read, one of `use` only once its class is known to be a plugin, so that a configuration of a repository someone merely checks cannot run other code by naming it. A rule of the project is named by its class in NEON; one built by a factory, a recipe evaluated at every build, is given by `dresscode.php`. In the decisions an entity stands only in the maps of the upgrading data, whose grammar takes one (`isMethod(POST)`). The schema keeps only the keys the file gave, so that an absent key keeps the default of the constructor, and it takes a single entry of `use` without a list (`use: nette`). -`Loader::find()` walks upwards and takes, in each directory, the local file before the `.dist` template; the two formats side by side are an ambiguity it refuses rather than resolves. A configuration file is the only thing laid over a project, and `--config` names it (its format follows its extension); a wrapper binary that wants a configuration to apply in a project without one hands `Console\Application` a `defaultConfig`, which the loader uses when it finds no file, and the workers get it because they are started with the same script. Without a file the root is the directory of the nearest `composer.json`, else the working directory, as for the target version, so that a run from a subdirectory shares the cache, the relative paths and the patterns of a `defaultConfig` with a run from the root. The command line is no key of a file: it is a profile laid over everything the file says, `--use` naming a preset or a plugin as `use` of a file does, `--set path=value` a decision (the value is NEON, decoded as the value in a file) and `--fix-risky=` what it accepts; `--only` is a parameter of `RunnerFactory::resolve()` and `--strict-rules` one of `RunnerFactory::createRunner()`. +`Loader::find()` walks upwards and takes, in each directory, the local file before the `.dist` template; the two formats side by side are an ambiguity it refuses rather than resolves. A configuration file is the only thing laid over a project, and `--config` names it (its format follows its extension); a wrapper binary that wants a configuration to apply in a project without one hands `Console\Application` a `defaultConfig` and its own `script`, the loader using the configuration when it finds no file, and the workers getting it because they are started with that script. Without a file the root is the directory of the nearest `composer.json`, else the working directory, as for the target version, so that a run from a subdirectory shares the cache, the relative paths and the patterns of a `defaultConfig` with a run from the root. The command line is no key of a file: it is a profile laid over everything the file says, `--use` naming a preset or a plugin as `use` of a file does, `--set path=value` a decision (the value is NEON, decoded as the value in a file) and `--fix-risky=` what it accepts; `--only` is a parameter of `RunnerFactory::resolve()` and `--strict-rules` one of `RunnerFactory::createRunner()`. ### The catalogue A rule declares what it decides in `Rule::getDecisions()`, and the decisions of every registered rule, with those of a tree its rules name, are the catalogue (`Config\Catalogue`), which is what a file is read against, what `explain` and the reference print and what an editor of the configuration loads. A `Decision` has a path, which is the identity of everything a user ever sees about it, the report and the suppression included, a `Domain` of the values it takes, a description and notes saying what it is not about; it is a requirement, which turns its rule on wherever it is not `keep`, a parameter, which only refines a requirement, turns nothing on and has a default instead of `keep` (`controlFlow.trailingIfMinStatements`, `naming.except`; the flag `upgrading.packages` turns nothing on either, but what it lets in is a layer of requirements, the maps of `upgrading.api` and of plugins and the requirements of the rules of the plugin of a package its upgrading files turn on, which then take effect with nothing of the project), or a fact, a key of the environment the rule guards (below). A rule runs where one of its requirements takes effect, so a rule is never named to be turned on: a project or a preset writes the decisions it wants, and a decision nobody writes requires nothing. A decision of one rule is declared by that rule; one several rules share, or one the engine reads, is declared by a tree, the decisions of the manifest of the plugin bringing the rules (`PluginManifest::$decisions`, the core being `Config\CorePlugin`), and each rule taking it names its path in `RuleInfo::$decisions`, a decision of the core or of its own plugin, never the rule that would otherwise own it, so that a decision over constructs with nothing in common to share is several rules and not one rule of several parts, and its words are written once. The other way round, rules that would repeat the same mechanism over different constructs (a table of claims, a call written another way) are one rule with a decision for each construct, every part reporting under its own and doing nothing where it is `keep`; a part that comes to need logic of its own is split off again. It turns on every rule naming it that can run, and it takes effect while one of them can; a rule that only reads a decision of a tree, to leave to it what it governs, names it in `RuleInfo::$reads`, which puts it in the catalogue and in the values of the run without turning the rule on (`Catalogue::getReadersOf()`, the `readers` of `toArray()`); where two decisions reach one occurrence, the rule of the broader one leaves it to the narrower one whatever its value, `keep` included, knowing what to leave from what the other rule makes public (`StrictComparisonArgumentRequiredRule::StrictArguments`, `NoUnreachableCatchesRule::isUnreachable()`) and never from the values of the run, and where two rules could both take it the order of their registration decides; a decision of a tree no registered rule names is not in the catalogue, and a rule declaring a path a tree declares, or two rules declaring one path, is a `ConfigurationException`. Where no catalogue is at hand, a test running one rule, `Catalogue::collectDecisions()` gives the decisions of the rule and `Catalogue::fromRules()` the catalogue of a few rules, the trees taken from the core and from the plugin the package of each rule names in `extra.dresscode.plugin` (`PackageDiscovery::findPluginOf()`), so that a plugin tests its rules as they run. -The domains are the kinds of value a key takes, each normalizing what a layer wrote into a `Value` and merging two of them by its law: `Words` (`forbidden`, `nextLine`, a tolerance of several where the domain takes one, the first of them the one written: `[perLine, fill]`), `Shapes`, the shapes of a construct each written by its example (`spacing.call: 'foo($a, $b)'`), which shows the whitespace its decision governs and every other whitespace in it as all the standards write it, so that it is read as that one space and nothing else, a shape no such example shows alone being a word, a tolerance of several where the decision declares one, every example in it passing and the first written where none matches (`spacing.logicalNot: ['!$x', '! $x']`), which the tool writes in single quotes and a configuration has to quote, NEON reading `foo($a, $b)` as an entity, the example only ever compared with the examples of the domain and never parsed, and the word naming the shape the rules read never written by a layer, `Count` (`2`, `1-2`, `1+`, a word where the decision names one, `file.lineLength: none`), `Flag`, `Names` (a list of names, regular expressions or an order, an empty set having no item and an empty order or a set a requirement acts on being refused, as is `keep` among the items), `Text` (a text of the project's own the rule writes as it is given), `GrammarEntry` (a value a grammar of its own reads, an entry of a map of the upgrading data) and `Map` (names to values of another domain, the maps of the upgrading data among them). The eight are the whole set: a plugin declares its decisions with them, never with a domain of its own, so `Domain::normalize()` and the constructor of `Value` are `@internal`. `keep` is the one word for "leave this alone" and every requirement takes it, a map entry by entry (`name: keep`) and as a whole, which withdraws what the layers below said, the data of the packages included, an entry a later layer writes standing alone, so `null`, `false` and a turned-off rule never stand side by side for the one idea. `Value` keeps the layer that said it, and a rule reads the values of its decisions from `Values` in `configure()`, never a raw array. A value is written one way in every output, by `Value::toWrittenText()` (`config`, `explain`, the reference, the catalogue): a word bare, an example in single quotes, `1-2` and `1+`, `yes` and `no`; `Value::toData()`, the normalized content, stays the identity of the value the result cache keys on, so a change of the writing invalidates nothing. A map whose entries a grammar reads as a whole, a map of the upgrading data, is a `Map` carrying that grammar (`Map::$grammar`, a schema of `nette/schema` over the merged entries), and its rule reads it with `Values::readMap()`, which hands over what the grammar makes of the entries no layer withdrew, while the grammar takes `keep` still, for a section of an upgrading file read alone; a wrong entry is an error of the configuration naming the path. +The domains are the kinds of value a key takes, each normalizing what a layer wrote into a `Value` and merging two of them by its law: `Words` (`forbidden`, `nextLine`, a tolerance of several where the domain takes one, the first of them the one written: `[perLine, fill]`), `Shapes`, the shapes of a construct each written by its example (`spacing.call: 'foo($a, $b)'`), which shows the whitespace its decision governs and every other whitespace in it as all the standards write it, so that it is read as that one space and nothing else, a shape no such example shows alone being a word, a tolerance of several where the decision declares one, every example in it passing and the first written where none matches (`spacing.logicalNot: ['!$x', '! $x']`), which the tool writes in single quotes and a configuration has to quote, NEON reading `foo($a, $b)` as an entity, the example only ever compared with the examples of the domain and never parsed, and the word naming the shape the rules read never written by a layer, `Count` (`2`, `1-2`, `1+`, a word where the decision names one, `file.lineLength: none`), `Flag`, `Names` (a list of names, regular expressions or an order, an empty set having no item and an empty order or a set a requirement acts on being refused, as is `keep` among the items), `Text` (a text of the project's own the rule writes as it is given), `GrammarEntry` (a value a grammar of its own reads, an entry of a map of the upgrading data) and `Map` (names to values of another domain, the maps of the upgrading data among them). The eight are the whole set: a plugin declares its decisions with them, never with a domain of its own, so `Domain::normalize()` and the constructor of `Value` are `@internal`. `keep` is the one word for "leave this alone" and every requirement takes it, a map entry by entry (`name: keep`) and as a whole, which withdraws what the layers below said, the data of the packages included, an entry a later layer writes standing alone, so `null`, `false` and a turned-off rule never stand side by side for the one idea. `Value` keeps the layer that said it, and a rule reads the values of its decisions from `Values` in `configure()`, never a raw array. A value is written one way in every output, by `Value::toWrittenText()` (`config`, `explain`, the reference, `init`, the catalogue): a word bare, an example in single quotes, `1-2` and `1+`, `yes` and `no`; `Value::toData()`, the normalized content, stays the identity of the value the result cache keys on, so a change of the writing invalidates nothing. A map whose entries a grammar reads as a whole, a map of the upgrading data, is a `Map` carrying that grammar (`Map::$grammar`, a schema of `nette/schema` over the merged entries), and its rule reads it with `Values::readMap()`, which hands over what the grammar makes of the entries no layer withdrew, while the grammar takes `keep` still, for a section of an upgrading file read alone; a wrong entry is an error of the configuration naming the path. The core keeps to its sections, a plugin to the one its manifest names after it (`PluginManifest::$section`), and the rules of the project to `project`; the catalogue refuses a path outside them, a path two rules declare and a section of a plugin that is a reserved key or a section of the core. A rule of a plugin names a tree of the core, of its own plugin and of the plugins it builds on (`PluginManifest::$plugins`, transitively), a rule of the core one of the core alone, so that a decision is shared across plugins only where one depends on the other. A rule of the project names a tree of the core or of any plugin the project loads, the project standing on all of them. The order of the registration is the order the rules of a stage run in, the core in the fixed list of `Config\CorePlugin`, the plugins after it in the order `use` names them, each after the plugins it builds on, the rules of the project last, so that no order of the keys of a file, which an editor may rewrite, changes what a run does. A decision of the core has its page on dresscode.run by its path, and a report in a terminal links to it. @@ -255,7 +257,7 @@ A plugin stands in `use` of the configuration beside the presets, by class or as ### What the packages bring -A package installed with the project speaks without being named: `Config\PackageDiscovery` reads `vendor/composer/installed.json` of the nearest `composer.json`, the root package included, and takes from every package what its `composer.json` says under `extra.dresscode`: `plugin`, a class loaded where `use` of the configuration names the package or the plugin, the root package needing no name, a plugin bringing code that runs in every check and therefore let in by the project as Composer lets in its own plugins, the others being a warning (a package of the vendor `dresscode` is named with the vendor or without it, as a preset of the core is, a package named so makes its name no preset, and a package and a preset of the same name are an error; a key of `extra.dresscode` that DressCode does not know, `PackageDiscovery::Keys` being the ones it does, is a warning of the run and a problem for `Testing\UpgradingTester`, the package possibly being newer than the tool), and `upgrading-dir`, the directory of the upgrading files, every NEON file in it, `upgrading/.neon` by convention (`PackageDiscovery::findUpgradingFiles()`). Such a file says what the versions of a library retired and what to write instead: `package` names the package whose versions the sections are of (the carrier itself, or the library a package of rules carries the file for), and every section `since ` holds the maps that version brought, the renames it made as `replacedClasses` and `replacedMembers`, a map of a rule of a plugin by the path of its decision, and a rule of the plugin the package ships itself turned on by the path of its requirement and a word (`acme.statusEnum: required` from the version that made the rewrite right), which a section above the lowest version leaves off without reporting anything. A map the project writes under `upgrading.api` itself turns its rule on, and `upgrading.packages: yes` lays the maps and the requirements of every file under it; a file names no intent, because the same entry is an offer of a newer form when a version brings it and a retirement once a later one drops the old, which the section `since ` already says. The sections the lowest version the code must work with reaches are merged in the order of their versions whatever order the file writes them in (newest first, as a guide to upgrading is written), a later one having the last word on an entry, every one where any version does, none where the versions have no lower bound, a package the project does not require among them, none for a package the project does not have, while a section above the lowest version that a version the constraint allows reaches is reported without a fix, the code meeting it wherever it runs on that version: what it forbids stays forbidden and what it replaces, a class, a function, a member or a call by the shape of its arguments, becomes the entry of the forbidden map with the replacement in its sentence, unless a section reached already writes the key, while an annotation forbids nothing by itself and says nothing (`UpgradingData::$reported`); an entry whose fix is right before the version that retired what it replaces says from which one, `{write: …, from: '8.0'}`, and is rewritten wherever the lowest version reaches that one, the map getting the entry without it, and the versions of the sections holding what is not rewritten stay with the data (`UpgradingData::$unreached`). The file is read and checked by `Config\UpgradingFile::fromFile()` and laid out for a constraint by `selectFor()`; the data keep the words of the file, and the version that retired an entry of a forbidden map (`UpgradingData::$retirements`) ends its sentence only where the data are laid (`UpgradingData::buildDecisions()`, ``Class `Acme\Old` is deprecated: replaced by `Acme\New`, since acme/lib 4.0``), so that `Testing\UpgradingTester` checks the sentence its author wrote; the key is spelled `since 3.10` because NEON reads a bare `3.10` as the number 3.1. A value is handed over as NEON reads it, so what it reads as an entity (`isPaid()`) is for the domain of the map to take. A member is spelled the way PHP reads it, `Class::name` for a constant or a method, whichever the code reaches, `Class::name(...$args)` for a method alone where a constant of that name exists too (parentheses make a key of replacedMembers a method whatever they hold, while replacedCalls reads them as the shape of the arguments, empty ones as none, and a placeholder with a type in front of it, `array $options` or `list $values`, as one only for an argument the types say is of it; the template unpacking such an argument, `...$options`, is risky where its keys are not seen, it being neither an array literal nor a list), `Class::$name` for a property and `Class::$name::get` or `Class::$name::set` for one of its two uses, a read (`isset()` among them) or a write (any assignment, `unset()`), as the hooks of PHP divide them, which is how `replacedCalls` gives the expression of each and `forbiddenMembers` forbids a write alone, and the replacement is the name alone for the same class, `Class::name` for another, or `\function` for the global function a method becomes. What a version took back, a later section withdraws with the value `keep` in place of the replacement, and so does any layer of the project, in the map of `replacedClasses` and of `replacedFunctions` as well; the entry never reaches the rule (`Upgrading\MemberMapGrammar`, the grammar such maps share, which also takes a value NEON reads as an entity, `isMethod(POST)`, and reads its arguments the way NEON gives them: a placeholder, a number, `Class::NAME` and a nested call as written, any other string as a string, quoted or not; what that cannot say is a string holding the whole code). +A package installed with the project speaks without being named: `Config\PackageDiscovery` reads `vendor/composer/installed.json` of the nearest `composer.json`, the root package included, and takes from every package what its `composer.json` says under `extra.dresscode`: `plugin`, a class loaded where `use` of the configuration names the package or the plugin, the root package needing no name, a plugin bringing code that runs in every check and therefore let in by the project as Composer lets in its own plugins, the others being a warning (a package of the vendor `dresscode` is named with the vendor or without it, as a preset of the core is, a package named so makes its name no preset, and a package and a preset of the same name are an error; a key of `extra.dresscode` that DressCode does not know, `PackageDiscovery::Keys` being the ones it does, is a warning of the run and a problem for `Testing\UpgradingTester`, the package possibly being newer than the tool), and `upgrading-dir`, the directory of the upgrading files, every NEON file in it, `upgrading/.neon` by convention (`PackageDiscovery::findUpgradingFiles()`). Such a file says what the versions of a library retired and what to write instead: `package` names the package whose versions the sections are of (the carrier itself, or the library a package of rules carries the file for), and every section `since ` holds the maps that version brought, the renames it made as `replacedClasses` and `replacedMembers`, a map of a rule of a plugin by the path of its decision, and a rule of the plugin the package ships itself turned on by the path of its requirement and a word (`acme.statusEnum: required` from the version that made the rewrite right), which a section above the lowest version leaves off without reporting anything. A map the project writes under `upgrading.api` itself turns its rule on, and `upgrading.packages: yes` lays the maps and the requirements of every file under it; a file names no intent, because the same entry is an offer of a newer form when a version brings it and a retirement once a later one drops the old, which the section `since ` already says. The sections the lowest version the code must work with reaches are merged in the order of their versions whatever order the file writes them in (newest first, as a guide to upgrading is written), a later one having the last word on an entry, every one where any version does, none where the versions have no lower bound, a package the project does not require among them, none for a package the project does not have, while a section above the lowest version that a version the constraint allows reaches is reported without a fix, the code meeting it wherever it runs on that version: what it forbids stays forbidden and what it replaces, a class, a function, a member or a call by the shape of its arguments, becomes the entry of the forbidden map with the replacement in its sentence, unless a section reached already writes the key, while an annotation forbids nothing by itself and says nothing (`UpgradingData::$reported`); an entry whose fix is right before the version that retired what it replaces says from which one, `{write: …, from: '8.0'}`, and is rewritten wherever the lowest version reaches that one, the map getting the entry without it, and the versions of the sections holding what is not rewritten stay with the data (`UpgradingData::$unreached`). The file is read and checked by `Config\UpgradingFile::fromFile()` and laid out for a constraint by `selectFor()`; the data keep the words of the file, and the version that retired an entry of a forbidden map (`UpgradingData::$retirements`) ends its sentence only where the data are laid (`UpgradingData::buildDecisions()`, ``Class `Acme\Old` is deprecated: replaced by `Acme\New`, since acme/lib 4.0``), so that `Testing\UpgradingTester` checks the sentence its author wrote; the key is spelled `since 3.10` because NEON reads a bare `3.10` as the number 3.1. Each section is laid as a layer of its own, an entry reached by its `from` in a layer of the section with that version (`Layer::$library`, `$since`, `$from`), so that `dresscode config` says where a value came from (`application.neon of dresscode/upgrade-nette, since 3.2`) and a rule reporting an entry passes its section on (`Values::getEntryOrigin()`, `MemberMapEntry::$origin`, `RuleContext::report(source:)`) to `Violation::$source`, which the format `json` writes as `source`; every entry stands in one of those layers, so their order decides nothing `selectFor()` has not decided already. A value is handed over as NEON reads it, so what it reads as an entity (`isPaid()`) is for the domain of the map to take. A member is spelled the way PHP reads it, `Class::name` for a constant or a method, whichever the code reaches, `Class::name(...$args)` for a method alone where a constant of that name exists too (parentheses make a key of replacedMembers a method whatever they hold, while replacedCalls reads them as the shape of the arguments, empty ones as none, and a placeholder with a type in front of it, `array $options` or `list $values`, as one only for an argument the types say is of it; the template unpacking such an argument, `...$options`, is risky where its keys are not seen, it being neither an array literal nor a list), `Class::$name` for a property and `Class::$name::get` or `Class::$name::set` for one of its two uses, a read (`isset()` among them) or a write (any assignment, `unset()`), as the hooks of PHP divide them, which is how `replacedCalls` gives the expression of each and `forbiddenMembers` forbids a write alone, and the replacement is the name alone for the same class, `Class::name` for another, or `\function` for the global function a method becomes. What a version took back, a later section withdraws with the value `keep` in place of the replacement, and so does any layer of the project, in the map of `replacedClasses` and of `replacedFunctions` as well; the entry never reaches the rule (`Upgrading\MemberMapGrammar`, the grammar such maps share, which also takes a value NEON reads as an entity, `isMethod(POST)`, and reads its arguments the way NEON gives them: a placeholder, a number, `Class::NAME` and a nested call as written, any other string as a string, quoted or not; what that cannot say is a string holding the whole code). Such a file sets maps, the requirements of the rules of the plugin of its own package and, under `namespaces` as the configuration writes them, what the namespaces of the package declare for the files a project writes into them, and nothing else: no package may decide how the code of the project is written, so a requirement of the core or of another plugin is a `ConfigurationException` (`PluginRegistry::findPackageSection()`). `ConfigResolver` lays them under every layer of the project, where `upgrading.packages` is `yes`; a map this DressCode does not know is a warning, because the package may be newer than the tool. PHP is a library like any other: what its versions retired is `src/Rules/Upgrading/php.neon` in the same grammar, the data of the platform package `php` (`PackageDiscovery::readPhpData()`), cut by the target of PHP as a package is by its constraint and laid where the requirement `upgrading.php.deprecatedApi` is `forbidden`, which the standard `nette` says without the data of the libraries, and what they say is reported under that requirement, not under the map (`Values::getEntryDecision()`, `MemberMapEntry::$decision`), so that `only`, `severity` and a suppression name it as any other; a fix right before the version that deprecated the function says so with `from`, `curl_close()` doing nothing since PHP 8.0 though deprecated in 8.5, and the values of arguments PHP deprecated, which no map can name, are `NoDeprecatedPhpArgumentsRule` under `upgrading.php.deprecatedArgument`. The result cache needs nothing new, the values a rule ends up with being in its identity, and so are the versions of the installed packages. @@ -263,9 +265,9 @@ Such a file sets maps, the requirements of the rules of the plugin of its own pa A preset is a file of the shape of the configuration under a name, `src/Presets/.neon` for those of the core, a file a plugin names in its manifest, or any file `use` gives by its path. It carries decisions, the presets it uses, `suppressionComments` and `severity`, nothing else: what is the project's, the facts, the scope, `fixRisky`, `reportOnly`, a plugin and `rules`, `NeonReader::readPreset()` refuses, so a file laid by `use` never gains what the file of the project can do. A name without a vendor is the one of the core, so `perCs` and `dresscode/perCs` are the same preset. A preset opens with a comment, its title and what it is, and says what a decision does, where its key and value do not, in a short comment after the value. -The presets of the core are the three standards, "standard" being the word for a preset that carries a style and says how the code looks: `psr12`, `perCs` on top of it, `nette` on `perCs` and the sets `cleanup`, `types` and `correctness`, with tabs and the layout of the Nette libraries. What a project wants beyond the looks of its code is a set, a preset of one intent used beside the standard (`use: [nette, modernizations]`): `modernizations`, `deprecations`, `cleanup`, `correctness`, `types` and `compilerOptimizations`. A set decides nothing about the layout, so it never fights the standard, and a decision the core gains goes into its set, so it reaches every project using the set without a change of its configuration. A standard may use a set and keep some of its decisions, as `nette` keeps the native types and an else after a return; a project naming that set beside the standard gets none of them back, the set being laid once, below the standard, and the run says so; the project writes those decisions itself. `tests/DressCode/Presets/sets.phpt` holds that every construct and function newer PHP brought or retired is in a set and lists, with the reason, the decisions a project writes itself. A decision of a rule of a plugin reaches a project when a preset it uses or the project writes it. `psr12` and `perCs` follow their specifications section by section and add no style of their own, so a rule whose fixed behaviour contradicts a specification gets a value rather than a preset-side workaround. Without a configuration file nothing applies: DressCode has no style of its own to fall back on, so a run refuses, unless `--use` names what to use, which is then all the run has, so that a run over a foreign project can ask for that alone. +The presets of the core are the five standards, "standard" being the word for a preset that carries a style and says how the code looks: `psr12`, `perCs` on top of it, `nette` on `perCs` and the sets `cleanup`, `types` and `correctness`, with tabs and the layout of the Nette libraries, `symfony` on `perCs` and `laravel` on `perCs`, the preset laravel of Laravel Pint. What a project wants beyond the looks of its code is a set, a preset of one intent used beside the standard (`use: [nette, modernizations]`): `modernizations`, `deprecations`, `cleanup`, `correctness`, `types` and `compilerOptimizations`. A set decides nothing about the layout, so it never fights the standard, and a decision the core gains goes into its set, so it reaches every project using the set without a change of its configuration. A standard may use a set and keep some of its decisions, as `nette` keeps the native types and an else after a return; a project naming that set beside the standard gets none of them back, the set being laid once, below the standard, and the run says so; the project writes those decisions itself. `tests/DressCode/Presets/sets.phpt` holds that every construct and function newer PHP brought or retired is in a set and lists, with the reason, the decisions a project writes itself. A decision of a rule of a plugin reaches a project when a preset it uses or the project writes it. `psr12` and `perCs` follow their specifications section by section and add no style of their own, so a rule whose fixed behaviour contradicts a specification gets a value rather than a preset-side workaround. Without a configuration file nothing applies: DressCode has no style of its own to fall back on, so a run refuses and points to `dresscode init`, unless `--use` names what to use, which is then all the run has, so that a run over a foreign project can ask for that alone. -`multiline.trailingComma` removes the comma of a list written on one line wherever its place is not `keep`, so `perCs` and `nette` also fix `function f($a,)`, `use ($a,)` and a `match` on one line whose last arm is followed by a comma. +`multiline.trailingComma` removes the comma of a list written on one line wherever its place is not `keep`, so `perCs`, `nette` and `symfony` also fix `function f($a,)`, `use ($a,)` and a `match` on one line whose last arm is followed by a comma. For `symfony` that is a deliberate departure from `no_trailing_comma_in_singleline` of PHP CS Fixer, and so is the comma `optional` removes where the closing bracket follows it on the line of the last item, which `trailing_comma_in_multiline` leaves. ### Resolving the layers @@ -279,15 +281,17 @@ An override (`overrides` in NEON, `Override` in PHP) is a profile for a part of The excluded paths only ever add up, the default list, those of the plugins and those of the configuration, so no layer can bring back what another left out. -`only` (`--only`, a parameter of the run and no key of a file) narrows a run to the decisions it names, and it changes no value: the tree is resolved whole and `only` lays a mask over it, `Values::isSelected()`, so a requirement outside the mask is neither reported nor fixed, while what the rules write keeps the values the project decided (a run narrowed to a rewrite of a structure in a project with four spaces writes the new block with four spaces, not a tab). A rule reads the value with `Values::get()` and asks whether a requirement is selected separately, and reading a value never enforces what the run did not select. A name of `only`, as of `fixRisky` and `severity`, is a decision, a section or a structure for every decision under it, a package of upgrading data, one of the vendor `dresscode` written without it too, for the maps and the requirements its files set, so the rules reading them, core and plugin alike, and only its maps where no other name covers a map (`ConfigResolver::collectPackageMaps()`), a library the same way for the files about it, whichever package ships them (`php` for the data of PHP), a name that is both a package shipping files and a library the files of another package are about standing for both, a preset for every decision it and what it uses write, or the class of a rule for its requirements. A section that shares its name with a preset (`types`) is written `types.*`, the bare name being refused as one that says both. A section or a preset narrows the run to those of its rules the rest turns on, never more, since its name is no decision about any one of them; where the project turned some of them off the run says which by their requirements, the first five, and a name that lets nothing in is an error rather than a run that checks nothing, naming `--use --only ` where the configuration does not use it. A name whose rules the target or the packages of the project leave out is skipped with a warning instead, so that one command serves projects of several targets, and is an error only where no other name of `only` runs. The result cache keys on the selection, and the workers get it on their command line. `fix --only` fixes those decisions and leaves the rest, so the next full `fix` changes more, which is the point and not a failure to converge. +`only` (`--only`, a parameter of the run and no key of a file) narrows a run to the decisions it names, and it changes no value: the tree is resolved whole and `only` lays a mask over it, `Values::isSelected()`, so a requirement outside the mask is neither reported nor fixed, while what the rules write keeps the values the project decided (a run narrowed to a rewrite of a structure in a project with four spaces writes the new block with four spaces, not a tab). A rule reads the value with `Values::get()` and asks whether a requirement is selected separately, and reading a value never enforces what the run did not select. A name of `only`, as of `fixRisky` and `severity`, is a decision, a section or a structure for every decision under it, a package of upgrading data, one of the vendor `dresscode` written without it too, for the maps and the requirements its files set, so the rules reading them, core and plugin alike, and only its maps where no other name covers a map (`ConfigResolver::collectPackageMaps()`), a library the same way for the files about it, whichever package ships them (`php` for the data of PHP), a name that is both a package shipping files and a library the files of another package are about standing for both, a preset for every decision it and what it uses write, or the class of a rule for its requirements; a name of another tool is refused with the decisions that cover it, which only a suppression takes. A section that shares its name with a preset (`types`) is written `types.*`, the bare name being refused as one that says both. A section or a preset narrows the run to those of its rules the rest turns on, never more, since its name is no decision about any one of them; where the project turned some of them off the run says which by their requirements, the first five, and a name that lets nothing in is an error rather than a run that checks nothing, naming `--use --only ` where the configuration does not use it. A name whose rules the target or the packages of the project leave out is skipped with a warning instead, so that one command serves projects of several targets, and is an error only where no other name of `only` runs. The result cache keys on the selection, and the workers get it on their command line. `fix --only` fixes those decisions and leaves the rest, so the next full `fix` changes more, which is the point and not a failure to converge. ### What the commands print -`dresscode explain ` says what one decision is for: its description, the values it takes with what each means, its notes, the value here with the layer that said it, the values of the three standards and the rule that owns it with what it requires. With a section it explains every decision under it; without an argument it explains the configuration as one document, by sections, drawn on the console, and with `--format markdown` it writes that document to the standard output as Markdown instead of drawing it. Both are written in Markdown by one `Console\ExplainPrinter` and the console draws that Markdown (`Markup::formatMarkdown()`), so that a fact is written once and the two never part; there are no tables. +`dresscode explain ` says what one decision is for: its description, the values it takes with what each means, its notes, the value here with the layer that said it, the values of the five standards and the rule that owns it with what it requires. With a section it explains every decision under it; without an argument it explains the configuration as one document, by sections, drawn on the console, and with `--format markdown` it writes that document to the standard output as Markdown instead of drawing it. Both are written in Markdown by one `Console\ExplainPrinter` and the console draws that Markdown (`Markup::formatMarkdown()`), so that a fact is written once and the two never part; there are no tables. `dresscode config` prints that resolution in the shape of the file, every decision a layer said with the layer in a comment (`# dresscode/perCs`, `# the override for tests/`), why it takes no effect where it does not, whether it lies outside `--only` and whether its risky fixes are accepted, and `--preset ` prints one preset alone, what it uses included. `--file` narrows it to what the run uses for one file and `--format json` gives it as data: its `version`, the facts in the words of the configuration, the decisions by path, each with `value`, `layer`, `inactive`, `selected` and `severity` (`error` or `warning`), and the rules by class with the reason a rule does not run as a word (`turnedOff`, `php`, `package`, `types`, `narrowed`, `onlyOverride` or `notMentioned`) beside the sentence. A name in `fixRisky` whose rules run nowhere, not even in an override, is a warning of every run. -`dresscode catalogue` prints the catalogue of the installed set and `--format json` gives it as data, versioned (`Catalogue::Version`): every decision with its kind, its domain as data, its description and notes, the rules declaring it with what each requires and the values of the three standards as a configuration writes them. It is the interface an editor of the configuration is built on, and `composer reference` writes `docs/reference/decisions.md` from the same data. +`dresscode catalogue` prints the catalogue of the installed set and `--format json` gives it as data, versioned (`Catalogue::Version`): every decision with its kind, its domain as data, its description and notes, the rules declaring it with what each requires, the values of the five standards as a configuration writes them and the names of the other tools whose rules it covers. It is the interface an editor of the configuration is built on, and `composer reference` writes `docs/reference/decisions.md` from the same data. + +The output of `init`, `upgrade` and `migrate` gives every word one meaning and every count its unit: a *finding* is what a rule reports in the code, an *automatic change* a fix made without asking, a *manual task* a finding no fix makes, left to a person, an *accepted exception* a suppression with its reason, one without a reason not accepted yet, an *excluded file* a file the scope leaves out with the reason, an *unverified file* one a check could not process, and *unchecked* a decision written as `keep`. A *place* is where the code makes a decision, which init measures, and a *topic* the unit of an upgrade, as its plan file names it. ### The target version of PHP @@ -295,7 +299,7 @@ The versions of PHP the rules target are a property of the checked project, neve The version a rule asks about travels as a plain string compared with `version_compare()`, never with the string operators, under which 8.9 outranks 8.10, and always in the short form `Config\Versions::shortenVersion()` gives, without the zeros a normalized one pads with, because `version_compare()` takes `8.1` for older than `8.1.0`. NEON reads a bare number as a version whose minor is a single digit, which every PHP ever released has had, so `8` and `8.0` both mean 8.0, and `php` in `targets` may carry a patch version, so no rule takes the string apart by position. The running interpreter is never the answer, so that the same file gets the same verdict from any directory under any PHP; only `Lexer` asks about the runtime, to emulate the tokens its own build does not know. -What PHP itself declares is the same kind of question, and a rule asks `Analyses\PhpSymbols` about it rather than `function_exists()` or reflection, which would answer with the extensions and the version of the machine: the catalog holds every version from 8.0 on, so what depends on the version, which versions declare a function at all, is compared with the target, and what PHP retired is in its upgrading data, `src/Rules/Upgrading/php.neon`. The parameters of those functions are a catalog of their own, `Analyses\PhpSignatures`, because they are two thirds the size of the rest and the command line compiles what it loads on every run, so only a rule that asks about them pays for it; it answers with `Parameter`, whose `canReplace()` says whether an argument written for one parameter may stand at another and whose `default` is known where every version from 8.0 on declares the same scalar, and it knows nothing of the extensions its source cannot see, where a rule then has no answer rather than a wrong one. A rule whose construct exists only from some version on says so with `requires: ['php' => '>=8.4']` in its `#[RuleInfo]`, a Composer constraint written as the `require` of `composer.json` writes it, all of it read by `composer/semver` (`Config\Versions`) and never by hand, and a range, a single version being refused because it would turn the rule off for every other release; an upper bound says a rule writes what a later version took away. It is metadata and never a directory, so that a rule lives with its topic and the reference can index the catalogue by the version a rule needs, and `ConfigResolver` leaves it out below that version: silently when a preset wrote its decisions, with a warning when the project wrote them. A rule that needs the version more finely than "run me or not" asks `RuleContext::$phpVersion` instead, and never about anything PHP 8.0 already had. The version in `requires` and the one read from `RuleContext::$phpVersion` say since when the fix is right, never since when the code it replaces is a problem: the target is the oldest version the code runs on, not the newest, so the upgrading data of PHP remove `curl_close()` only where every version of the target has made it a no-op, while a rule about a deprecation whose replacement older versions know too (`NoNullArrayKeysRule`, `NoSleepAndWakeupRule`, `RedundantArgumentsRule`) asks no version at all, the code meeting the deprecation wherever it runs on a later one, and neither does `OptimizedCallNotationRule` writing the arguments of an optimized call, an argument written by position being right everywhere and optimized wherever the code runs on 8.4 or later. A fix naming a class of PHP needs the version that declares it, though older ones parse the name and ignore an attribute they do not know: `OverrideAttributeRequiredRule`, `SensitiveParameterRequiredRule` and `DeprecatedAttributeForAnnotationRule` require the version of their attribute, code naming a class the target does not declare being no fix for that target. A package in `requires` is the same question asked of `Config\ProjectPackages`, which answers from the `composer.json` of the project and the `installed.json` beside it, never from the Composer of the running process: for a package the project requires itself the versions are those its constraint allows, because code written for a newer one breaks wherever the constraint lets an older one in, so a constraint without a lower bound allows every version, for a package that only comes with another, which the project promises nothing about, it is every version too, the installed one never answering, the alias of a development branch the project requires standing for the newest of its line, a package an installed one replaces with its own version, a part of a monorepo installed as the whole, is had in the version of the one replacing it, and any version does for the project itself and for a branch without an alias. The packages in `targets` of the configuration (`targets: {acme/mailer: '3.3'}`) say the version of an installed package outright and come before all of that (`ProjectPackages::withTargets()`), so that code is fixed for a version while the one before it is still installed and its members still have a declaration to read; the version is quoted, because NEON reads a bare `3.10` as the number 3.1. The resolver leaves a rule whose package the project does not have, or has in a version its requirement does not allow, out as it does a rule too new for the target, and the sections `since` of a package's deprecations are cut by the lowest of those versions. A rule that makes no sense without the types of the code says so with `typesRequired`, and the resolver leaves it out where the configuration sets no `typeAnalysis` (a fact of the project, which a preset may not state): silently when a preset or the data of a package wrote its decisions, so that a preset may write them whatever the project has, and as a `ConfigurationException` when the configuration or the command line wrote them, because the project then asked for what it cannot get and a run without the rule would lie. The registry builds `Analyses\Types` only where the configuration asked for the types, so `getAnalysis()` of them throws elsewhere. A rule that would only do better with them asks `RuleContext::findAnalysis()`, which answers `null` where the run has none (`ReplacedClassesRule` then writes a class without asking whether the project has it), or does without them altogether, and says in its docblock what it then cannot see, in one shape: `Without the types, is not told from .` Its correctness never rests on them: without the types the rule breaks nothing, what it cannot tell apart being risky or left alone, and the types only narrow that. Such a rule answers in three states (`Tristate`): for certain yes, the fix is safe; for certain no, the rule says nothing, never offering a fix it knows is wrong; maybe, the fix is risky with `Risk::TypeUnknown`. The declarations in sight give the answer first (a parameter, a property, a method of `$this`, whose types a child may not turn into another), and the types narrow what they leave as maybe, through a question `Types` already answers (`isSubtype()` in `StaticForMethodWithoutThisRule`, `findParameters()` in `PipeForNestedCallsRule`). A question of `Types` about a fact whose no a caller may act on answers in `Tristate` too (`isOfType()`, `isSubtype()`, `isInterface()`, `isFinalClass()`, `isAttributeClass()`, `isStaticMethod()`, `isPlainProperty()`, `isComparedAlike()`), maybe for a class nothing declares and for one whose hierarchy is not seen whole, a parent, an interface or a trait of it or of an ancestor being one nothing declares; a question asking whether something may be done answers `bool`, `true` only where it certainly may (`canReplace()`, `matchesParentSignature()`). `hasMember()` answers `bool` although it asks about a fact, a class nothing declares having no member: what the maps of members ask with it is whether the code still reaches a declaration, and a member a library removed together with its class is as undeclared as one removed from the class alone. +What PHP itself declares is the same kind of question, and a rule asks `Analyses\PhpSymbols` about it rather than `function_exists()` or reflection, which would answer with the extensions and the version of the machine: the catalog holds every version from 8.0 on, so what depends on the version, which versions declare a function at all, is compared with the target, and what PHP retired is in its upgrading data, `src/Rules/Upgrading/php.neon`. The parameters of those functions are a catalog of their own, `Analyses\PhpSignatures`, because they are two thirds the size of the rest and the command line compiles what it loads on every run, so only a rule that asks about them pays for it; it answers with `Parameter`, whose `canReplace()` says whether an argument written for one parameter may stand at another and whose `default` is known where every version from 8.0 on declares the same scalar, and it knows nothing of the extensions its source cannot see, where a rule then has no answer rather than a wrong one. A rule whose construct exists only from some version on says so with `requires: ['php' => '>=8.4']` in its `#[RuleInfo]`, a Composer constraint written as the `require` of `composer.json` writes it, all of it read by `composer/semver` (`Config\Versions`) and never by hand, and a range, a single version being refused because it would turn the rule off for every other release; an upper bound says a rule writes what a later version took away. It is metadata and never a directory, so that a rule lives with its topic and the reference can index the catalogue by the version a rule needs, and `ConfigResolver` leaves it out below that version: silently when a preset wrote its decisions, with a warning when the project wrote them. A rule that needs the version more finely than "run me or not" asks `RuleContext::$phpVersion` instead, and never about anything PHP 8.0 already had. The version in `requires` and the one read from `RuleContext::$phpVersion` say since when the fix is right, never since when the code it replaces is a problem: the target is the oldest version the code runs on, not the newest, so the upgrading data of PHP remove `curl_close()` only where every version of the target has made it a no-op, while a rule about a deprecation whose replacement older versions know too (`NoNullArrayKeysRule`, `NoSleepAndWakeupRule`, `RedundantArgumentsRule`) asks no version at all, the code meeting the deprecation wherever it runs on a later one, and neither does `OptimizedCallNotationRule` writing the arguments of an optimized call, an argument written by position being right everywhere and optimized wherever the code runs on 8.4 or later. A fix naming a class of PHP needs the version that declares it, though older ones parse the name and ignore an attribute they do not know: `OverrideAttributeRequiredRule`, `SensitiveParameterRequiredRule` and `DeprecatedAttributeForAnnotationRule` require the version of their attribute, code naming a class the target does not declare being no fix for that target. A package in `requires` is the same question asked of `Config\ProjectPackages`, which answers from the `composer.json` of the project and the `installed.json` beside it, never from the Composer of the running process: for a package the project requires itself the versions are those its constraint allows, because code written for a newer one breaks wherever the constraint lets an older one in, so a constraint without a lower bound allows every version, for a package that only comes with another, which the project promises nothing about, it is every version too, the installed one never answering, the alias of a development branch the project requires standing for the newest of its line, a package an installed one replaces with its own version, a part of a monorepo installed as the whole, is had in the version of the one replacing it, and any version does for the project itself and for a branch without an alias. The packages in `targets` of the configuration (`targets: {acme/mailer: '3.3'}`) say the version of an installed package outright and come before all of that (`ProjectPackages::withTargets()`), so that code is fixed for a version while the one before it is still installed and its members still have a declaration to read; the version is quoted, because NEON reads a bare `3.10` as the number 3.1. The targets of a command line, PHP among them, lie over those of the configuration before the upgrading data are cut (`RunnerFactory::mergeTargets()`), so that a run for versions the project has not moved to yet sees the sections of those versions as reached. The resolver leaves a rule whose package the project does not have, or has in a version its requirement does not allow, out as it does a rule too new for the target, and the sections `since` of a package's deprecations are cut by the lowest of those versions. A rule that makes no sense without the types of the code says so with `typesRequired`, and the resolver leaves it out where the configuration sets no `typeAnalysis` (a fact of the project, which a preset may not state): silently when a preset or the data of a package wrote its decisions, so that a preset may write them whatever the project has, and as a `ConfigurationException` when the configuration or the command line wrote them, because the project then asked for what it cannot get and a run without the rule would lie. The registry builds `Analyses\Types` only where the configuration asked for the types, so `getAnalysis()` of them throws elsewhere. A rule that would only do better with them asks `RuleContext::findAnalysis()`, which answers `null` where the run has none (`ReplacedClassesRule` then writes a class without asking whether the project has it), or does without them altogether, and says in its docblock what it then cannot see, in one shape: `Without the types, is not told from .` Its correctness never rests on them: without the types the rule breaks nothing, what it cannot tell apart being risky or left alone, and the types only narrow that. Such a rule answers in three states (`Tristate`): for certain yes, the fix is safe; for certain no, the rule says nothing, never offering a fix it knows is wrong; maybe, the fix is risky with `Risk::TypeUnknown`. The declarations in sight give the answer first (a parameter, a property, a method of `$this`, whose types a child may not turn into another), and the types narrow what they leave as maybe, through a question `Types` already answers (`isSubtype()` in `StaticForMethodWithoutThisRule`, `findParameters()` in `PipeForNestedCallsRule`). A question of `Types` about a fact whose no a caller may act on answers in `Tristate` too (`isOfType()`, `isSubtype()`, `isInterface()`, `isFinalClass()`, `isAttributeClass()`, `isStaticMethod()`, `isPlainProperty()`, `isComparedAlike()`), maybe for a class nothing declares and for one whose hierarchy is not seen whole, a parent, an interface or a trait of it or of an ancestor being one nothing declares; a question asking whether something may be done answers `bool`, `true` only where it certainly may (`canReplace()`, `matchesParentSignature()`). `hasMember()` answers `bool` although it asks about a fact, a class nothing declares having no member: what the maps of members ask with it is whether the code still reaches a declaration, and a member a library removed together with its class is as undeclared as one removed from the class alone. ### The namespaces of the project @@ -303,7 +307,41 @@ What the namespaces of the project declare is a property of the project too, and `ConfigResolver` puts both into `ResolvedConfig`, so they are in the identity of the result cache and in `dresscode config` with the layer that named each symbol, and `Analyses\Registry` builds `NameResolver` with them as `NamespacedSymbols`, which is also the analysis a rule asks for the lists themselves. -A certain resolution rests on lists that must stay complete, so the key turns on their guard, `NoUnlistedNamespacedDeclarationsRule`, whose decisions are the facts `namespaces.functions` and `namespaces.constants`: it reports a declaration missing from them under the path of the list, `--only` never narrows it away, since a fix resting on the lists must not lose what keeps them true, and a file whose resolution ends up uncertain has no guard; a declaration the lists do not name is decidable in its file, a declaration inside a condition and a `define()` with its name written as a string among them (`NameResolver::findNamespacedDeclarations()`), and the lists can then go stale only through a path left out of the run or a package of the same namespace. What a package declares for the files a project writes into its namespace is said by the data of the package (`namespaces` of its upgrading file), which lies under the lists of every layer; `dresscode config` names the source of each. +A certain resolution rests on lists that must stay complete, so the key turns on their guard, `NoUnlistedNamespacedDeclarationsRule`, whose decisions are the facts `namespaces.functions` and `namespaces.constants`: it reports a declaration missing from them under the path of the list, `--only` never narrows it away, since a fix resting on the lists must not lose what keeps them true, and a file whose resolution ends up uncertain has no guard; a declaration the lists do not name is decidable in its file, a declaration inside a condition and a `define()` with its name written as a string among them (`NameResolver::findNamespacedDeclarations()`), and the lists can then go stale only through a path left out of the run or a package of the same namespace. What a package declares for the files a project writes into its namespace is said by the data of the package (`namespaces` of its upgrading file), which lies under the lists of every layer, and for a package that does not say it yet DressCode knows it itself (`PackageDiscovery::Known`, the functions of the container configurator of Symfony for `symfony/dependency-injection`); `dresscode config` names the source of each. + +### init + +`dresscode init` writes `dresscode.neon` from how the code is already written, and it has no measuring code of its own: every number it gives is a probe, a check of a configuration over files of the project counted by decision (`Measuring\ProbeRunner`, its `ProbeResult`), and a decision is measured by running its rule under each of its values, what the rule reports being what disagrees with that value. + +The scope (`Measuring\Scope::fromProject()`) is what the formatter of the project checks where its configuration names the paths (`` of PHP_CodeSniffer, `->in()` of PHP CS Fixer, whose configuration is read from its syntax tree, never run for it: the calls of its finder with literal arguments, `exclude()`, `notPath()` and `notName()` among them, what else narrows it named as not read, `Interop\PhpCsFixerSource`), written as those paths, one that does not exist refused as the formatter refuses it and one outside the root left out; else it is what git tracks under the root, a file it would add inside the paths being said as not measured, the first run taking it too, written as the entries of the root that hold its files, directories and single files alike; without git, or in a directory the repository tracks no code of, it is what the `autoload` and `autoload-dev` of the nearest `composer.json` name with the conventional directories the root has beside them (`Scope::Paths`, where a name with `*` is a pattern of the root), a path inside another one dropping out and `.` standing for a project neither names, and a file they name one by one, in `files` or a classmap, written as a path of its own where no directory of the scope holds it (`paths: [src, helpers.php]`). What it leaves out goes into `excludePaths` with its reason in a comment (`LeftOutReason`): a file whose head says a generator wrote it or that `linguist-generated` of `.gitattributes` marks, as the highest directory inside the paths of which every file is generated where there is one; the data of tests, a directory of `Scope::TestDataDirs` named by itself, so that it stands for itself at any depth; what git ignores inside the paths; and a file that does not parse, found by the parse of the namespaces and by the checks of init. A test in the format PHPT, a `.phpt` with a line of a section such as `--FILE--` in its head, is no PHP and leaves the scope; the extensions written are those of the files the scope holds, and a file of another one needs no exclusion. A file of the scope that none of the checks reads and that does not parse stays in it, and `check` says so. + +The sample is the whole scope up to a thousand files and 8 MB, else it is shared among the directories (`FileSample`), those of the root or, where the scope lies in one directory, those below it down to where they part: each an even share of `FileSample::MaxFiles` files **and** `FileSample::MaxBytes` bytes, a smaller one all it has, and within it every k-th of its sorted files, so that a large directory does not crowd the others out and the same tree always gives the same sample. The budget is of bytes too because the cost of measuring is bytes and not files; where no file fits, the smallest is the sample, as one file is evidence and none is not. The report says how many files each directory gave, and a directory whose files write a decision of the file (the indentation, `file.*`) otherwise than the configuration does, in five of them at least and in nine tenths of those writing one value, with the override that would keep it there (`Proposal::findRegions()`); none is written, as init proposes one style for the project and an override is the project's to decide. + +A scope without a PHP file stops init before any probe, saying where it looked and that the configuration is then written by hand. The probes run as processes of `check --format json` of this very DressCode (`ProcessProbeRunner`), never of the script that called it, as many at once as the machine has processors, the processors split among them as `--jobs`; their configurations lie in the system temp, and the hidden option `--root` makes the project the root of each run. A process of a probe carries `DRESSCODE_PROBE` in its environment and starts no probes of its own, so the processes never grow beyond one level. `InProcessProbeRunner` runs the same configurations in the process, for the tests and where no PHP binary is found to start one. + +The configuration of another formatter (`Interop\FormatterConfig`, the formatter an `Interop\Formatter`) is found where a project keeps it, the files of the root in the order the formatter looks for them (`.php-cs-fixer.php` before `.php-cs-fixer.dist.php`), and chosen by the strongest evidence that the project runs it (`Interop\FormatterSearch`, each an `Interop\FormatterCandidate` with its `Evidence`): a script of `composer.json` running the formatter, read as the words of the shell and through the scripts it calls by `@name`, with the file its `--config` or `--standard` names, then a package of the formatter the project requires, then the file alone. Where the evidence leaves several, a terminal asks which one, and elsewhere the first in the order of `Interop\Formatter` is taken and the others are named, in `init` and `migrate config` alike. For the scope and the standard it is read as data and never run: the ruleset of PHP_CodeSniffer is XML, the file of PHP CS Fixer is PHP read from its syntax tree, whatever of its finder the reading cannot be sure of, several finders, a call under a condition, a method it does not know, a variable of a finder assigned again or a class named `Finder` of another vendor, being told as not carried over (`PhpCsFixerSource::$unread`), so a configuration that runs code of the project runs none of it unless the user agrees to it (below). What it leaves out leaves the scope too (`LeftOutReason::Foreign`), the paths a single rule of it leaves out keep the decisions that rule stands for unchecked there, as `overrides` with `keep`, and the report names it and says where the project runs it, so that `dresscode check` can take its place. + +The standard (`Measuring\StandardChoice`, with the reason it was chosen) is the one given with `--use`, for a project formatted by Laravel Pint (the formatter chosen is Pint, or no formatter has a configuration and the project has `laravel/pint`) the standard of its preset, none for a `pint.json` that is no JSON, `laravel`, `psr12`, `perCs` for `per` or `symfony`; otherwise each complete standard is checked over the sample, `laravel` only for a project of Laravel or with Pint, whose preset says nothing of code without it, and the one a fix under it alone changes the fewest files of the sample under is taken, the metric the report names, as a difference the whole project has weighs more than one a single place has, which a count of the decisions would weigh the same; except that `psr12` yields to `perCs`, its successor, where a fix under `perCs` changes at most half again as many files and the formatter does not name `psr12`: PSR-12 says nothing of what PHP brought since, so code written to PER Coding Style departs from it in less. A standard a rule failed under in a file is not measured whole, its count possibly short, so it is taken only where others are measured whole; where a rule failed under every standard there was to choose from, none can be chosen as the nearest, and init writes nothing and ends with exit code 2 (`StandardChoice::$unsure`). A file a rule failed in under any probe of the measuring or the checks is left out of what it counts (`Proposal::findFailedFiles()`): the head of the file names it, init says the decisions rest on the other files and that `check` reports it rather than that it passes, and ends with exit code 2, as a configuration measured on part of the code. A report of a probe without its files is a failed probe. Where the next one is within a tenth of it, the one the other formatter names decides among them, then `perCs`; a framework the project requires decides nothing, as an application of a framework need not write in its style. The check of the standard over the sample gives its residue, the decisions the code departs from it in, and only those are measured, everything else being what the standard says and the code already does. Every decision of the residue is probed alone, no standard beside it, under the values its domain tells (`Measuring\MeasurementPlan`, which also decides from what the probes report what the file writes): the words or the shapes, a count from 0 to 2 within its bounds and the counts the standards give it, and the value letting all of them pass where the domain takes a tolerance or a range. The probes run in two layers: the indentation, the width of a line and the line ending first (`MeasurementPlan::FirstLayer`), their values laid under every probe of the second layer, so that the rules spreading a line by its width see the width the code has; the width is measured on a ladder of `file.lineLength` with `file.longLines` (`MeasurementPlan::LineLengths`), the smallest step at most a hundredth of the files exceed. + +A decision of several values is counted by files where it is a property of the file (the indentation, `file.*`) and by places otherwise (`Measurement::fromFileProbes()`, `fromPlaceProbes()`): a place agreeing with one of k values is reported by the other k − 1, a place agreeing with none by all k and once by the probe allowing every value, so the places are what the values report less those in none, divided by k − 1. A file a rule fails in under any of the values is left out of the decision for all of them, because it would agree with the value it failed under. What the code says (`Classification`): a value at least `Measurement::Threshold` of at least `Measurement::MinPlaces` places agree with is settled, one all of fewer places agree with is settled weakly, the values the code has together are tolerated where no one reaches the threshold, the decision takes a tolerance and together they do, and anything else is mixed. A settled or tolerated value is written beside a comment with its evidence ("your code, 93% of 176 files"), unless it is what the standard says; a mixed one is `keep` with a comment beginning "not checked:". A decision of one value, whose rule reports only what disagrees, has no share, only the files its fix would change: at most a tenth of the sample and the standard's value stands, more and it is `keep`. A policy, a list the project brings, is `keep`. + +Then the configuration is checked over the whole scope (`Proposal::verify()`), and a decision whose fix would change files is a cost, the files its fixes without a risk would change. The budget (`Measuring\Budget`) is a hundredth of the files, two at least and twenty at most, or `--budget`: decisions are left unchecked, `keep` with the cost in the comment, until the files the rest would change together fit, each time the one whose files the others change least, which leaves the fewest, the costlier first among equals, checked again until they do, as a decision left unchecked changes what the others fix, and the last check gives what the first fix changes and what it leaves. The decisions of the first layer and those of `indentation.*` are never left unchecked, as every other line stands on them, and the files they change, indented otherwise than the rest of the code, are fixed whatever the budget and said apart. What a fix leaves, a place no rule fixes or one whose fix is risky, goes to the baseline: the report tells to run `dresscode baseline` after `dresscode fix`, which writes the file of the default name a run finds by itself, so the configuration names none. The checks of init run without the types of the code, which would start PHPStan in every probe; where the configuration asks for them, it is checked once more with them over the whole scope before it is written (`Proposal::verifyWithTypes()`), and the line `Types` says what the first fix then changes and leaves. + +The file is written by `Config\Draft`, as `dresscode migrate config` writes its translation: each key with what it rests on in a comment, the decisions under their sections in the order of the catalogue, a group where its first decision stands, and `dresscode.php` the same without the comments. What the namespaces declare is not a decision and not sampled: every file of the scope naming a namespace or `define` is parsed, because a declaration a sample left out would be taken as global with nothing to report it, a file that does not parse leaves the scope, and `nameResolution: certain` is written with the lists only where every file that may declare was read, a file that does not parse possibly declaring what they lack, the measuring going the same way; `typeAnalysis: phpstan` is written where the project has `phpstan/phpstan`, the one fact of the project `init` writes without measuring it, because a project with PHPStan wants the risky fixes a type decides made where the types tell they are safe; the measuring goes without them, as they would make it slow, so the configuration is checked once more with them. The files the autoload loads one by one declare for the namespaces too, wherever they lie, and a file that cannot be read leaves the scope as one that does not parse. The target of PHP is read as a run reads it and only said, never written: a target older than PHP 8.0 stays as it is, and the rules see it. What the file says is read back and resolved before it is written and must be what was measured (`Draft::renderChecked()`), the types aside where no PHPStan is installed beside DressCode, which then goes without the types the file asks for, and a configuration that exists is never overwritten: the report says so as its last line and the proposal is not printed, whose length would push the report out of sight; `fixRisky` is never written. + +What is the user's to decide init does not decide for them, it asks (`Measuring\Questions`, each a `Question` with its `Answer`s and a place of the code it is about): first a close call of standards the formatter did not decide, whose answer measures again against the other standard, then, after the check over the whole scope, at most five questions of decisions, the costliest first: a value the budget left unchecked, a value of the code the rules of the formatter contradict, and names no rule renames that share a prefix or a suffix, read from the code the rule reports, which the baseline lists as the recommended answer and `naming.except` may let pass, now and later. Every question's recommended answer, the first, never rewrites code the measuring kept, and Enter takes it; an answer that keeps the value of the code writes it as the user's, which the budget never leaves unchecked. The configuration is checked again with the answers where they changed it, and what that check leaves over the budget is the next round of questions, asked whatever their number, as they follow from the answers. Whether to take the standard as it is is no question: the line `Measured` says how many files of the sample it would rewrite, mostly for one decision the code holds to everywhere, such as where a standard that moved writes `declare(strict_types=1)`. Without a terminal, with `--yes` and with `--format json` init asks nothing and takes the recommended answers, so the file is the one Enter gives, and prints each question as the line of `dresscode.neon` or the command that answers it otherwise; the JSON document (`InitPrinter::toArray()`) says its `version` and the `root`, the scope, the sample, the standard with the files a fix changes under each, every value with its comment and origin, the regions, what the check over the whole scope says, those lines as `instructions`, the files a rule failed in, the configuration and the file it was `written` to, over a configuration that exists what has moved as `shifts` (`Proposal::findShifts()`), and nothing else is written to the output. The rules of the formatter are those its configuration translates to; a configuration of PHP CS Fixer is PHP, which translating runs, so init translates it only where `--formatter` named it or the user agrees in a terminal, as `migrate config` does (`CommandContext::chooseFormatterConfig()`, `confirmReadingRules()`). The end of the report says whether those rules were compared with the code or not read at all, never carried over either way, and that `dresscode migrate config` translates them instead of measuring the code, as the summary of `migrate config` names `init` for the other way. `init --set` gives a decision a value whatever was measured (`Proposal::answer()`), written with "your answer" and never left unchecked by the budget, as is an answer. With `--dry-run` init prints the configuration after the report and writes nothing, `--jobs` limits the processors its probes share, `1` running them in-process, and `--formatter` names the configuration of the formatter to take where the evidence chose another. Over a configuration that exists init compares the code with it, the standard and the decisions it gives, over the files it checks (`Scope::fromConfig()`), those an override gives decisions of their own left out and said, a comparison left with no file ending with exit code 2, the presets it names resolved with its plugins, which a probe names by their class and runs with in this process where the configuration is of PHP, which may declare them; it asks nothing and takes no answer, as it writes nothing, `--set` being refused, a file that cannot be read stopping it with why before anything is measured, and a local file chosen before a `.dist` template as the loader chooses it; measures against the standard that file names and prints every decision it would write otherwise with the files a fix under the value of the file would rewrite, and every one the file sets apart from the standard while the code holds to the standard, which no departure from the standard shows (`InitPrinter::printShifts()`), so that running it again after an upgrade of DressCode or a while of work says what has moved; init over its own file says nothing has. + +The output says each phase as it comes, a status line while it runs with a spinner and the probes done, the spinner turning on through the one long probe of the check of the whole scope: the project and what is left out of it, the version of PHP, the standard and what the code departs from it in, the values taken from the code, what a fix of the whole scope changes and what it leaves, and the commands to run next. A probe running longer than half an hour ends init with that, its process ended with what it started, as every command DressCode runs is (`Engine\ProcessRunner`, its output into files, as a pipe on Windows is read only blocking). + +### upgrade + +`dresscode upgrade today` is `fix` for a project that only moves to a newer PHP or newer libraries, which it says by raising the lower bounds of its constraints: the set `deprecations` (with `--modernize` also `modernizations`) is laid over the presets the configuration uses and beneath its own decisions and overrides (`ConfigResolver::$laid`), so that what the project keeps stays kept in the upgrade too, and the command line lays the plugins of the packages of upgrading data, which no `use` has to name, and `typeAnalysis: phpstan` over it all, a run asking for the types refusing to go without PHPStan beside DressCode (`RunnerFactory::resolve()`), and the versions are those the constraints of the project allow, as for any run, PHP among them (`RunnerFactory::readProjectPackages()`). The preface says which packages are installed newer than the lowest version allowed, whose declarations then say nothing of what that one deprecates, advising `composer update --prefer-lowest`, and which packages of upgrading data are not required with a lower bound, so that their data are reported and never written (`ProjectPackages::findNewerInstalled()`, `isUnbounded()`). A project without a configuration file is the scope `init` would write, as for every command of an upgrade (`Upgrade\Project::fromConfig()`), with no style. A project with one keeps its scope, target, types, `fixRisky`, baseline and maps, and of its style only the decisions its code keeps: a check without the types and without the sets runs first over the code as it is, every decision it finds violated anywhere is left out whatever its severity, and the run is narrowed by `only` to the decisions left, the sets and the packages whose data reach the project, for the maps of their plugins no set covers (`Upgrade\Project::measureSatisfiedRequirements()`), which a worker gets through `--only` instead of checking again; `--only` of the command line narrows the run instead, and `--fix-risky`, `--ask-risky` and `--max-warnings` work as with `fix`, and the names are resolved for certain from what the namespaces of the scope declare (`Upgrade\Project::resolveNames()`). A file a generator wrote is left out, which the preface counts (`Upgrade\UpgradeFiles`). One run makes no fixed point over the files: where it rewrote a file, it advises running it again. The upgrading data of a library come from a package that says which libraries it is about under `extra.dresscode.packages`, as `dresscode/upgrade-nette` says `nette/`; discovery also finds one installed beside DressCode rather than in the project, a package of the type `dresscode-plugin` with upgrading files. DressCode knows no such package itself: what Packagist offers, for now of the vendor `dresscode` alone until a package of another vendor can be trusted, is asked once a day and remembered in the cache directory (`DataPackageCatalogue`), and `UpgradingTester::collectPackageProblems()` refuses a list that is not exactly the packages the upgrading files are about. A project requiring one of those libraries itself, in `require` or `require-dev`, a library coming with another one not counting, gets the package installed first, `composer require` into the installation of DressCode, or `require --dev` into the project where its lock holds DressCode: in a terminal writing for a human after asking, otherwise only with `--yes`, the run being refused with the command to run where neither holds (`Upgrade\DataPackageInstallation`); Composer is `Upgrade\Composer`, what it writes kept out of the output, and the command then starts again in a new process, whose autoloader knows the installed packages and the plugins they bring (`dresscode/upgrade-nette` brings one), or where it runs embedded in another program says to run it again, with exit code 4. + +An upgrade is about an `Upgrade\Project`, the configuration, the root, the configuration file and the command line, which lays one layer over the configuration for every run of an upgrade (`Project::createLayer()`). A run of a plan crosses three boundaries a test replaces to fail where it wants, given to `PlanRunner` by its constructor: the version control (`Upgrade\VersionControl`, `Git`), the packages (`PackageManager`, `Composer`) and the tests (`TestRunner`, `ProcessTestRunner`); the files and the state of a run stay real. What an upgrade to some versions costs is measured by `Upgrade\Estimator`, which changes no file: one check over the scope of the configuration (`Upgrade\UpgradeCheck`, which every check of an upgrade is) with the sets `deprecations`, `modernizations` and `cleanup` laid as `upgrade today` lays them, the packages of upgrading data that reach the project, the decisions `upgrading.unseen.*` and the targets, laid by the command line before the data are cut (`RunnerFactory::mergeTargets()`), and where the configuration says nothing of the names `nameResolution: certain` with what the namespaces of the scope declare (`Measuring\NamespaceDeclarations`), without which nearly every fix of a call of a function would wait as risky; the files the autoload of `composer.json` loads one by one declare too, wherever they lie, as they do for `init`, and a file that cannot be read, or that the autoload names and is not there, makes the names as uncertain as one that does not parse. Every violation is a place of a topic (`Upgrade\Topic`): the library of its entry of upgrading data (`Violation::$source`), else its decision, which are the names `--only` takes. Its phase (`Upgrade\Phase`) is the bridge, a fix made on the versions the project allows today, where the lowest of them reaches the version `from` or the section of the entry says, and for a place of no data where its decision runs for the project without the targets, which a second resolution tells; the jump otherwise. The text names it by the constraint its fixes are right on (`works on ^3.2`, `needs ^4.0`, `Topic::$constraint`): that of today in the bridge and the one the target raises it to in the jump, of the library of the topic, or of the one target where the topic is a decision of no library, the words `works today` and `after the raise` standing where neither is known. What the run can do about it (`Upgrade\Fixability`) is read off the violation: automatic, waiting for the types or for the names, or manual. A topic belongs to the first set whose decisions it is of, the data to `deprecations`. What the upgrade does not see is counted apart (`Upgrade\BlindSpots`): the places `Rules\Upgrading\UnseenReferencesRule` reports, a string naming a class the maps rename or forbid and a member, a class or a function reached by a name the code computes, the files of the scope no rule reads that an upgrade may change too (a template, a configuration) by their extension, and the files whose head says a generator wrote them, which the check leaves out. Where the project has a `composer.lock`, Composer is asked offline what of it refuses each target (`Upgrade\Composer`, `composer why-not --locked`, its lines parsed, the project itself left out), an answer it does not give within a minute or a failure being an unknown with its reason, never a failed run; the command is the script `COMPOSER_BINARY` names, run by the PHP of DressCode as Composer sets it for its scripts, or `composer` on the path, and an output that is no answer of `why-not`, which exits with 1 as a Composer that failed does, is an unknown too, and one that does not end in time is ended with its children. Unless the run is offline, Composer is asked too what raising the constraints to the targets as the plan raises them would move (`composer require --dry-run`, which needs the network, `Upgrade\Moves`, an unknown the same way), and the text lists the packages it moves across a major version, or a minor one below 1.0 (`Move::isMajor()`), the JSON document every one, each marked where the upgrading data do not reach the minor version it moves to (`Move::isKnown()`), `(no data)` where they stop below its major version, the hop a project makes with no help of DressCode, and `(data up to 4.1)` where they reach into it. Every failure of Composer is a `ComposerException` saying why, its last line or the time it ran out of, and naming the log in the temporary directory that keeps the command, its exit and all it wrote, which the questions of the estimate turn into an unknown and the raise of a run into a step left waiting. A package of upgrading data about a library the project requires, which neither the project nor DressCode has, is named with those libraries (`PackageDiscovery::findMissingDataPackages()`) and never installed. All of it is one `Upgrade\Estimate`, which `Console\EstimatePrinter` writes as text or as a JSON document of its own `version`, as `Console\PlanPrinter` writes the plan and `Console\RunOutcomePrinter` a run of it, as JSON in a document of its own with the plan it ran, the warnings of the run going to standard error. + +The command `upgrade` has its commands, and alone it lists them. `dresscode upgrade estimate [ []]` writes the estimate: for the package and the version named, for PHP up to the version named or the newest its upgrading data know, and without a target for what `Estimator::proposeTargets()` proposes, PHP and every library whose data know a version newer than the project allows. A project without a configuration is measured over the scope `init` would write, `--paths` narrows it, the types are those `--types` or `--no-types` says, the latter over a configuration asking for them too (`RunnerFactory` `withoutTypes`, which every run of the upgrade takes from its layer, the run of the project as it is today among them), else the configuration, else what the user answers in a terminal, a run outside one or writing a format other than `console` going without them, as the command asks nothing then (`UpgradeCommand::mayAsk()`), `--paths`, `--offline` and `--jobs` belong to the commands that measure, `estimate`, `plan` and `run` with a target, and `--offline` takes the catalogue of Packagist as the cache remembers it, however old. The options of a fix belong to `upgrade today` alone, so that the estimate never changes a file by accident. With `--jobs` the check runs in workers as `check` does, each repeating it from what the parent wrote into a file of the cache directory (`UpgradeCheck::write()`, with the scope of a project without a configuration, `UpgradeCheck::$scope`), which `--check-file` names, since the layer of the estimate holds what no option of the command line says. + +`upgrade plan` orders the estimate into the steps of an upgrade, written after the estimate, so that a plan never says less of what the upgrade cannot see or is not sure of (its JSON document holds the estimate under `estimate`) (`Upgrade\PlanBuilder`, `Upgrade\Plan`, each `Upgrade\Step` a `FixStep`, a `ComposerStep`, an `UpdateStep` or a `ManualStep`, the steps of Composer a `DependencyStep`): for each target something of the lock refuses, or Composer could not tell what does, a step for a human with the `composer why-not` to run; the fix of the topics of `deprecations` in the bridge; the raise of the constraints of `composer.json`, each in the shape the project writes it and with its upper bounds (`Upgrade\Constraints`): a part of `||` the target is past dropped and the lower bound of the others raised (`^8.4` for `^8.1`, `>=8.4` for `>=8.1`, `>=8.4 <8.5` for `>=8.1 <8.5`), and where the target is past every part, the last raised the same way (`~8.4.0` for `~8.1.0`), one with an upper bound of its own as a caret, which the title of the step says, left out where the lowest version allowed already reaches the target; the fix of the topics of `deprecations` in the jump; the fix of `cleanup`; and the fix of `modernizations`, which the plan leaves out until the user takes it in. A step is a recipe, never its result: a fix names its topics as `--only` takes them, and whatever it fixes is measured anew when the run comes to it, so the plan saved by `upgrade plan --save` into `dresscode-upgrade.neon` (`Upgrade\PlanFile`), with the version of its shape, the versions of DressCode and of the upgrading data it was made with, the commands of the tests, the paths `--paths` narrowed the estimate to, which its fixes, `upgrade tasks` and the suppressions it lists keep to, and what the estimate could not see under `estimate`, the files it could not process and the packages of upgrading data it lacked, which a run of the plan warns of, is a file the team reviews and edits, a step left out with `skip: true`; it is read strictly, a key it does not know, a value of another type, a name that is no package and a constraint Composer does not read refused with the path of the key, so that a typo never changes a run in silence. A plan of no step is saved as no file, so that a plan read is never empty. `upgrade run` with a target saves the plan and runs it, refusing to replace a saved one, which `upgrade plan --save` replaces; without a target it runs the saved one as it is, refusing the options that make a plan. Both need the configuration file, whose style the fixes keep (`Upgrade\PlanRunner`, which tells where it stopped by `Upgrade\RunStop`): each topic is fixed by a run over the project as it is, the sets laid beneath the configuration as `upgrade today` lays them, narrowed to the topic and the decisions the code keeps and without the targets, so that a topic of the bridge writes only what the versions allowed today know, and one of the jump, once Composer raised the constraints, what the new ones do. Whether a step is done is read from the code and the lock alone, a fix left with nothing to fix changing nothing, a manual step passing once `composer why-not --locked` says nothing of the lock refuses its target, the run waiting at it otherwise with what refuses it, or why Composer could not tell, a project without a lock among the reasons, and exit code 4, a step that is free left out with `skip: true`, and a step of Composer passing where `composer.json` requires every package of it with a lower bound reaching its constraint, its branches compared by version, and for one that updates where the version installed and the one of the lock are ones the step allows, and for PHP the `config.platform.php` the project sets, which a step raising PHP raises with it, a step of its own raising the platform alone where `composer.json` requires the target already and running `composer update --lock` after it (`ComposerStep::isDoneIn()`, `composer config platform.php`), so a run goes on from a working tree a team committed, squashed or rebased; at a step of Composer whose constraints are not raised yet it shows what `composer require -W --dry-run` would change and, once the user agrees (`$approve` of the run, `Console\Commands\RaiseApproval`, in a terminal after asking or with `--yes`, Composer not even asked without either), runs through the packages of the run (`PackageManager`) `composer require -W --prefer-lowest --minimal-changes` and goes on with the jump, the dry run being of copies of `composer.json` and the lock beside them, with the platform of PHP a step raises with it, so that it resolves what the raise will, a raise of the platform alone among it: the packages raised are at the lowest versions their constraints allow, which the code is written for and whose `@deprecated` the rewrites may read, the rest changed as little as the update needs, what that downgrades against the lock said apart before the user agrees, and the plan of an application ends with a step of Composer of its own updating every package to the newest version the constraints allow (`Upgrade\UpdateStep`, the kind `update` of the plan file), tested as every step and done once `composer update --dry-run` changes nothing; otherwise it stops with the `composer require` to run and exit code 4, and where Composer fails, with what it failed with (`RunOutcome::$composerFailure`). What the fixes of a run tell the user, an analysis that went without or names not resolved for certain, goes into its outcome as its warnings, in the text and in the JSON alike. A run of the whole plan goes on after every raise in a new process, which loads anew what the raise installed, the plugins, the libraries and the upgrading data among it, for the fixes, the tests and the check at the end alike (`RunStop::Raised`), and a raise no tests ran after yet is the one the journal keeps, so that a run started anew tests it too: the run hands over what it got to as data in a file a variable of the environment names (`RunOutcome::toArray()`), the new process goes on after the raise and alone says how far the whole run got, and a process started so hands back where it stopped at the next raise instead of starting one itself, its starter going on once for every step of Composer of the plan (`CommandContext::rerun()`, the program the one of the package, `WorkerPool::locateProgram()`); an embedded run, whose command line is not its own, stops after the raise with exit code 4. A library, a project of the `type` `library`, or of none, which Composer reads as `library`, without a lock of its own, keeps the versions it allows: its plan is the bridge and the constraints widened to the targets (`^3.2 || ^4.0` for `^3.2`), a step of Composer without `update` that requires them with `--no-update` (`ProjectPackages::isLibrary()`); one whose `type` says library while it has a lock of its own is asked about in a terminal and planned as the application the lock makes it outside one, and `--library` or `--no-library` decides it either way, the line `Mode` saying which and why; it is decided before the estimate of a plan, which for a library asks Composer nothing of what the update moves, as widening installs nothing. What a fix leaves for a human, a violation it has no fix for or a risky one it refused, is a task (`Upgrade\Task`), listed by the last fix of its topic (`Upgrade\TopicReporter`), a risky one with what its fix waits for and how to allow it; after the step a raise leads to the run waits while the step leaves any, with exit code 1, a plan is done only once no task is left, its end waiting the same way, once no comment puts a place off without a reason, as `upgrade tasks` reads them, and once the set `deprecations` finds nothing more in the whole scope, which the plan checks at its end, not only the topics its estimate found, a library installed by a raise showing what it deprecates only then (`RunStop::Remaining`, exit code 1, with the files `upgrade today` fixes), `--step` names a step the plan runs or is refused, and a task is done once the code no longer has it or a `dresscode:ignore` (best with ` -- ` and the reason) or the baseline confirms it. `upgrade tasks` lists the tasks of the saved plan as the code stands, the fixes run without writing, with those the check of the whole scope at the end of a run finds and no topic does under `deprecations`, and the comments `dresscode:ignore` about an upgrade that give no reason after ` -- `, which only put a task off, and the files the fixes cannot process (`PlanRunner::findTasks()`, `Upgrade\Postponement`, which reads the comments alone), with exit code 1 where a task or such a comment is left and 2 where a file is. A fix runs without writing and the run writes what it changes (`Upgrade\TopicReporter`), each file kept as it was before the step before it is first written (`Upgrade\Snapshots`), in the directory of the git repository of the project, which clearing the cache does not take, or in the cache directory with a warning where git finds none, a file changed since the fix read it left as it is, and going back leaves a file changed since the step wrote it as it is too, keeping its text before the step; a file a fix cannot process, one that does not parse or a rule fails on, stops the run after its step with exit code 2 (`RunStop::Unprocessed`, `RunOutcome::getExitCode()` mapping every stop), and the estimate names such files, whose places it does not count. A run that finds the snapshots a step left unfinished, interrupted or gone back over a file changed since, runs nothing (`RunStop::Interrupted`, exit code 1): it lists the files the step may have rewritten, none where the snapshots cannot be read, which `upgrade abort` puts back as they were before the step, a file changed since left as it is, or `upgrade run --discard` drops the snapshots and goes on from the code as it is; `upgrade plan --save` refuses to forget them the same way, unless given `--discard`. Where `--test-command` (with `{junit}` for the JUnit file it writes) gives the tests, they run once before the first step and after every step, the quicker `--quick-test-command` where given except after the step a raise leads to and after the last one, and the whole suite after a raise no fix follows, whose dependencies then stay raised where the tests fail, `composer.json` and the lock being kept as they were before the first raise until a step after it is done (`Upgrade\ComposerJournal`), so that `upgrade abort` puts back those the raise changed and nobody since and runs `composer install`, which the run says wherever it stops with them changed, the journal staying while a file is left or the install failed, so that the next `upgrade abort` goes on from there, and `upgrade abort --dry-run` says what it would put back and leave, changing nothing; `upgrade status` runs no fix and says where the saved plan stands, a step of Composer done or due by the constraints, a manual step by what the lock refuses, a fix and a step to the newest versions left to `upgrade run`, and what a run keeps, the steps it did not finish, the raise it journals, the step it left uncommitted and when the tests ran before the first step (`Upgrade\PlanStatus`), always with exit code 0: a step after which a test fails that passed before, or after which the tests write no results, goes back byte for byte and the run ends with exit code 1 (`Upgrade\TestResults`, a test known by its `classname` and `name` with the root left out, a class and a method for PHPUnit, a file for Nette Tester): each passed, skipped, failed or thrown (`TestStatus`), the worst of them where one is repeated under its identity: a test that passed before and does not pass now, or is gone, fails, said how it ended; a command running out of time, writing no JUnit report or one of no test gives no results, and one ending with another exit code than 0, or than the 1 and 2 PHPUnit and Nette Tester end with for tests that did not pass, crashed, each of them told why, which fails the step, and before the first one stops the run before it changes anything (`RunStop::Tests` without a step, exit code 2, the run being incomplete), each command has a baseline of its own, which the run says it compared with, when it ran and how many of its tests did not pass then, so that a run with no new failure is not read as one whose tests pass, and a command without `{junit}` is refused when the plan is made and when the file of the plan is read. With `--commit` every topic becomes a commit of its own, with the files it changed as the topic left them and the trailer `DressCode-Upgrade-Step`, once the tests of its step passed and it processed every file, made from those texts through an index of its own, so that the working tree is never written for a commit and the hooks of the repository run as for any other; a file changed after its step wrote it, by the tests or by a hand, leaves the step uncommitted in the working tree, as the commit would hold a text the tests did not pass over, and where git refuses a commit the run stops with what is left uncommitted and which topics of the step it committed before (`RunStop::Commit`, exit code 2), its outcome kept as any other; a step leaving files it could not process stays uncommitted, kept as such with the snapshots and the results of the tests before the first step (`Upgrade\RunState`), and the run that processes them commits it whole, refusing where the plan edited since says something else under its number unless `--discard` forgets it, those files and the ones the step wrote being no changes of the working tree of its own, and a raise the run makes a commit of `composer.json` and the lock where git does not ignore them, so that the next run finds no changes of its own (`Upgrade\Git`); since a commit takes the files whole, it is refused before anything changes where the tracked files have changes of their own, the saved plan aside, or where git finds no repository tracking the project. What a run keeps is written whole through a temporary file of its own, a snapshot before its index and the index before the file of the project, two runs never work in one project at once, a run and a plan being saved holding the lock of the project, and it is forgotten when the plan is done or a new one is saved. The JSON documents of `estimate`, `plan`, `run`, `tasks` and `status` say their `version` and the `root` of the project, and those about a saved plan (`plan --save`, `run`, `tasks`, `status`) its `planIdentity` too, a hash of the text of `dresscode-upgrade.neon` (`PlanFile::identify()`) by which a tool tells which plan a document is about; an agent reads them, the workflows having no format `agent`. ## Writing a rule @@ -321,7 +359,7 @@ The subject is the thing at the reported position, with a definite article and i A path names a thing in the code, by the name PHP gives it where it has one (`hygiene.is_null`, `mistakes.__serialize`) and by the name of the family where several things share the key (`upgrading.functions.arrayFirstLast`), under the section a reader looks for it in; nothing of how a decision behaves follows from its section. Its value says how the thing is written. In the sections of the style a decision with several shapes is a preference and names them (`sameLine`, `nextLine`), while one with `forbidden` or `required` has no second shape to choose: either the other one adds nothing or the project decides it as a policy. Under `upgrading` stands what a version of PHP above the floor decided, `required` writing a construct a newer PHP gave where the older way is found and `forbidden` writing the replacement of what PHP deprecated; under `mistakes` stands what is wrong or dangerous whatever the style. A decision that only refines another one is a parameter (`controlFlow.trailingIfMinStatements`), so that a list a project writes never turns a rule on by itself. -A path reads as a sentence: the section names the area, a group the question or the thing, the last link the place. A group stands only where it says what the section does not, in one of two ways: one question several places answer in one domain, the group naming the question or the thing the places belong to and its keys the places (`braces.position.class`, `multiline.trailingComma.array`, `qualification.function.global`), or one thing several questions are asked of, the group naming the thing and its keys the questions (`classes.modifiers.order`, `file.strictTypes.position`). A requirement whose only name is that of the thing stands flat, its parameters beside it carrying its name and saying what they refine (`controlFlow.trailingIf` and `controlFlow.trailingIfMinStatements`): a group named after the thing would leave the requirement a word that says nothing (`max`, `level`). A group holds a parameter beside requirements with names of their own, or parameters alone where they are one question asked of several places (`spacing.alignment.comma`, `multiline.fillMinItems.array`). A section asking one question of many places has no group (`naming`, `casing`), and a path has four links at most. What a key means is read at first sight, which comes before every other rule of the path: a group is named by a noun, the thing or the question (`strictTypes`, `position`, `trailingComma`), never by an adjective, a participle or a phrase with a preposition, and where no noun reads well the keys stand flat however long (`braces.emptyBody`, `controlFlow.elseAfterReturn`). `keep` on a group keeps every key of it, and a word, a count, a flag or a list on a group is the value of each of its keys of a closed vocabulary (words, counts, flags) that takes it (`multiline.operatorPosition: lineStart`, `qualification.function: imported`), a list giving each key the words of it the key takes, never a text, a list of names, a map or an example; a word no key takes, one that among requirements only a parameter takes, a word of presence (`required`, `forbidden`, `optional`) that a requirement of the group does not take or that is given to a group naming one thing several decisions ask about (`Catalogue::QuestionGroups`, where it would read as one about the thing itself), `[]` and `{}`, a list leaving a requirement none of its words, `keep` or `...` in a list and any value but `keep` on a section are refused. A value a group gives a decision whose rule the target or the packages of the project leave out is skipped silently, as one of a preset (`ResolvedDecision::$cascaded`), while the decision written by its path warns. Where the keys of a group are places, a kind of places stands for all of its places at once (`lists`, `operators`, `blocks`, `Config\Catalogue::PlaceKinds`), a place the same map names winning over its kind, while a later layer replaces whatever its value covers. A key of a configuration is one link of the path, the links nested a key under the other, and `DecisionResolver` refuses a key holding a dot, so that a decision has one way to be written; the dotted path only names a decision (`fixRisky`, `--only`, `dresscode:ignore`, the messages, the name of a map in an upgrading file), and what is given as paths (the header of a fixture, `RuleBuilder::resolveValues()`) is placed into the tree by `Engine\Helpers::placeValue()`. +A path reads as a sentence: the section names the area, a group the question or the thing, the last link the place. A group stands only where it says what the section does not, in one of two ways: one question several places answer in one domain, the group naming the question or the thing the places belong to and its keys the places (`braces.position.class`, `multiline.trailingComma.array`, `qualification.function.global`), or one thing several questions are asked of, the group naming the thing and its keys the questions (`classes.modifiers.order`, `file.strictTypes.position`). A requirement whose only name is that of the thing stands flat, its parameters beside it carrying its name and saying what they refine (`controlFlow.trailingIf` and `controlFlow.trailingIfMinStatements`): a group named after the thing would leave the requirement a word that says nothing (`max`, `level`). A group holds a parameter beside requirements with names of their own, or parameters alone where they are one question asked of several places (`spacing.alignment.comma`, `multiline.fillMinItems.array`). A section asking one question of many places has no group (`naming`, `casing`), and a path has four links at most. What a key means is read at first sight, which comes before every other rule of the path: a group is named by a noun, the thing or the question (`strictTypes`, `position`, `trailingComma`), never by an adjective, a participle or a phrase with a preposition, and where no noun reads well the keys stand flat however long (`braces.emptyBody`, `controlFlow.elseAfterReturn`). `keep` on a group keeps every key of it, and a word, a count, a flag or a list on a group is the value of each of its keys of a closed vocabulary (words, counts, flags) that takes it (`multiline.operatorPosition: lineStart`, `qualification.function: imported`), a list giving each key the words of it the key takes, never a text, a list of names, a map or an example; a word no key takes, one that among requirements only a parameter takes, a word of presence (`required`, `forbidden`, `optional`) that a requirement of the group does not take or that is given to a group naming one thing several decisions ask about (`Catalogue::QuestionGroups`, where it would read as one about the thing itself), `[]` and `{}`, a list leaving a requirement none of its words, `keep` or `...` in a list and any value but `keep` on a section are refused. A value a group gives a decision whose rule the target or the packages of the project leave out is skipped silently, as one of a preset (`ResolvedDecision::$cascaded`), while the decision written by its path warns. Where the keys of a group are places, a kind of places stands for all of its places at once (`lists`, `operators`, `blocks`, `Config\Catalogue::PlaceKinds`), a place the same map names winning over its kind, while a later layer replaces whatever its value covers. A key of a configuration is one link of the path, the links nested a key under the other, and `DecisionResolver` refuses a key holding a dot, so that a decision has one way to be written; the dotted path only names a decision (`fixRisky`, `--only`, `dresscode:ignore`, the messages, the name of a map in an upgrading file), and what is given as paths (the translations of `Interop`, the header of a fixture, `RuleBuilder::resolveValues()`) is placed into the tree by `Engine\Helpers::placeValue()`. Where a thing has a shape for each of several places, each place is a key of its own (`braces.position.class: nextLine`, `naming.method: camelCase`), and a list of places stands where one shape holds for all of them. A place is singular, in a key as in a value, and one place has one word across the catalogue: `class`, `anonymousClass`, `function`, `anonymousFunction`, `arrowFunction`, `method`, `constant`, `property`, `propertyHook`, `variable`, `enumCase`, `parameter`, `argument`, `instantiation`, `array`, `list`, `matchArm`, `closureUse`, `import`, `trait`, `traitUse`, `controlStructure`, `branch`, `switchCase`, `catch`, `return`, `body`, and for what spreads over lines `attribute`, `matchCondition`, `for`, `implements`, `condition`, `chain`, `ternary` and the operators by their family (`logical`, `concatenation`, `arithmetic`, `bitwise`, `coalesce`, `comparison`, `pipe`), a qualifier standing in front of the word where no group names it (`anonymousClass`, `promotedProperty`). The places of one group do not overlap: a narrower place takes its members from the wider one (`interfaceMethod` the methods of an interface from `method`, `anonymousClass` the instantiation of an anonymous class from `instantiation`), so that every occurrence is decided by one key; the places of a group fall into the kinds of `Config\Catalogue::PlaceKinds`, `lists`, `operators` and `blocks`. A key relating places keeps the plural English asks for (`blankLines.aroundMethods`), and so does a list of names the project brings (`mistakes.debugOutputFunctions`). A construct spread over lines takes its shape on two axes, `multiline.items` (`perLine`, `fill`) and `multiline.edges` (`apart`, `attached`, `closingApart`), a value being one shape or a list of the shapes accepted, the first of which is written where DressCode spreads the construct and on the axis whose shape the author's construct does not pass; a construct on one line always passes and a spread one is never joined, but for the arguments of `fillPlain`, every one of them plain, which are joined where they fit on the line of the opening parenthesis. Where a token stands is `sameLine` or `nextLine`, an operator at a break `lineStart` or `lineEnd`, so that a new rule speaks in the words a reader knows. @@ -331,12 +369,65 @@ A decision has a description, the sentence `explain` and the reference print fir An exception or a warning is read by someone who has to act, so it says where, what and what to do, in that order. Where is a prefix naming what the reader edits (`Configuration file X: `, `Upgrading file X: `, `Preset X: `, `The override for src/: `, `The command line: `), left out only when nothing but the command itself can be meant; what is a sentence about that thing, with the value the reader wrote or has to write; what to do follows after a semicolon, or as a second sentence when the first has one, and it is the only place an imperative may stand. A message the reader cannot act on, a broken rule or a failing worker, says whose fault it is instead. Every message is a capitalized sentence ending with a period, warnings included, `must` and never `has to`; a message wrapping another one puts it after a colon and adds nothing behind it, and what it wraps is left as its author wrote it. -Everything in such a message that is code stands in backticks, as in Markdown: a file and a path, a command and an option, a key of the configuration and a value (`` `x` given `` for a value refused), a decision, a preset, a package and the class of a rule, a class, a function, a member and an annotation (``Configuration file `dresscode.neon`: ``, ``set `typeAnalysis: phpstan` ``). A word that only speaks about code is not code (`the configuration`, `a preset`). The console draws what the backticks enclose in color and drops them; every other output, a pipe, a log of CI, the formats `json` and `github`, the reader of the PHP API, prints them as they are, which reads well to a human and to an agent alike. Where a page of the manual says more than the message, the exception carries the path of that page (`ConfigurationException::$docs`), and the console prints it under the message as a link to `https://dresscode.run/`. What the author of a plugin reads (`RuleInfo`, the claims, `RuleTester`) states the contract that was broken; what the user of a configuration reads never names an internal class, node or slot. +Everything in such a message that is code stands in backticks, as in Markdown: a file and a path, a command and an option, a key of the configuration and a value (`` `x` given `` for a value refused), a decision, a preset, a package and the class of a rule, a class, a function, a member and an annotation (``Configuration file `dresscode.neon`: ``, ``Run `dresscode init` ``, ``set `typeAnalysis: phpstan` ``). A word that only speaks about code is not code (`the configuration`, `a preset`). The console draws what the backticks enclose in color and drops them; every other output, a pipe, a log of CI, the formats `json` and `github`, the reader of the PHP API, prints them as they are, which reads well to a human and to an agent alike. Where a page of the manual says more than the message, the exception carries the path of that page (`ConfigurationException::$docs`), and the console prints it under the message as a link to `https://dresscode.run/`. What the author of a plugin reads (`RuleInfo`, the claims, `RuleTester`) states the contract that was broken; what the user of a configuration reads never names an internal class, node or slot. ### Testing `Testing\RuleTester` runs a rule over `*.code` fixtures (`.expected` output beside them, `.violations` as `line: message` with the risk of a risky one on an indented line below it, `risky ` with `: ` where the rule gives one, the values of its decisions in a `// {"json"}` comment, a key without its section taking the one section of the rule, over those of a `values.neon` beside the fixtures, a run that allows risky fixes in a `// risky` one, the code and the fixed code run under the CLI of PHP in a `// run` one, which must print the same and exit alike, the targeted version in a `// php 8.4` one, the types turned off in a `// types off` one, the widest line in a `// lineLength 80` one (120 without it; `check()` takes a whole `Style` instead, whose line ending stays that of the code) and what the namespaces declare outside the fixture in `// namespacedFunctions`, `// namespacedConstants` and `// nameResolution certain`, all in the comments that open the fixture; without a version the rule is tested at the version of PHP it requires, else at `Config::DefaultPhpVersion`, never at the version of the interpreter running the tests) and, given `analyses:` in the shape of `Config::$analyses`, registers those of a plugin for the rule, then checks the contract: parent invariant, idempotence over the output, comments preserved unless `modifiesComments`, `dresscode:ignoreFile` honored, no silent mutation. -`Testing\UpgradingTester` is for a package that ships upgrading files: `collectProblems($file, $root, $packageRules)` reads a file the way a run would, in the project whose vendor holds the library, the rules the package ships itself known besides those of DressCode, and gives back sentences. A key under `extra.dresscode` of the nearest `composer.json` at or above the file that `PackageDiscovery` does not read is a problem too (a run only warns of it, the package may be newer than the tool). Every section, reached or not, has to be what the grammars of its maps accept, and what the sections come to at the installed version has to replace, in `replacedClasses` and `replacedMembers` and by a static method in `replacedFunctions`, by classes, members and methods that exist, asked of the loaded code (a function of a library is declared by a file loaded with its first use, so it is not looked up); the other maps are held to their grammar alone. A replacement the file replaces in its turn (`REALPATH`, then `Realpath`, then `RealPath`) is followed to its end, the way the passes of a run follow it, and one that leads back to where it started is a problem; an entry withdrawn with `keep` is thereby not checked, and one whose withdrawal was forgotten shows as a replacement that does not exist. What is replaced is not looked up, a library having usually removed it. The sentence of a `forbidden…` map is read as the end of the message it completes: in lower case unless it begins with a name, with its code in backticks, with no period, double quote or "should", and at most 160 characters, the backticks not counted; an entry of a `forbidden…` map that gives none (a project may leave it out) is a problem in the data of a package. `runSample($code, $root, $decisions)` is the other half: code written for the old API as the decisions given fix and report it, every section the installed version reaches applying, whatever the constraint of the project allows, the types taken from the PHPStan of the project, which reads the code from `temp/` of the root, and no result cache; it gives the `FileResult`, so that a package tests its data without reaching for the `@internal` factory of the run. +`Testing\UpgradingTester` is for a package that ships upgrading files: `collectProblems($file, $root, $packageRules)` reads a file the way a run would, in the project whose vendor holds the library, the rules the package ships itself known besides those of DressCode, and gives back sentences. A key under `extra.dresscode` of the nearest `composer.json` at or above the file that `PackageDiscovery` does not read is a problem too (a run only warns of it, the package may be newer than the tool). `collectPackageProblems($root)` holds `extra.dresscode.packages` of the package to exactly the packages its upgrading files are about, by which `upgrade` offers it to a project lacking it. Every section, reached or not, has to be what the grammars of its maps accept, and what the sections come to at the installed version has to replace, in `replacedClasses` and `replacedMembers` and by a static method in `replacedFunctions`, by classes, members and methods that exist, asked of the loaded code (a function of a library is declared by a file loaded with its first use, so it is not looked up); the other maps are held to their grammar alone. A replacement the file replaces in its turn (`REALPATH`, then `Realpath`, then `RealPath`) is followed to its end, the way the passes of a run follow it, and one that leads back to where it started is a problem; an entry withdrawn with `keep` is thereby not checked, and one whose withdrawal was forgotten shows as a replacement that does not exist. What is replaced is not looked up, a library having usually removed it. The sentence of a `forbidden…` map is read as the end of the message it completes: in lower case unless it begins with a name, with its code in backticks, with no period, double quote or "should", and at most 160 characters, the backticks not counted; an entry of a `forbidden…` map that gives none (a project may leave it out) is a problem in the data of a package. `runSample($code, $root, $decisions)` is the other half: code written for the old API as the decisions given fix and report it, every section the installed version reaches applying, whatever the constraint of the project allows, the types taken from the PHPStan of the project, which reads the code from `temp/` of the root, and no result cache; it gives the `FileResult`, so that a package tests its data without reaching for the `@internal` factory of the run. The fixtures of every rule run again under each standard with the sets beside it (`robustness.phpt`), which no fixture of one rule tries: the run must not fail, write code that does not parse or fail to settle, whatever the other rules make of the code. What no fixture catches, the runs over the corpus do: the whitespace fuzz damages the whitespace of a fixed file and requires the fixer to come back to it, and `name-meaning.phpt` runs the rules that write names and imports over the corpus and requires every name outside the imports to resolve to what it resolved to before, because an import moved into a statement writing its items differently, or dropped while something still uses it, leaves code that parses and round-trips and says nothing else. + +## Interop: the rules of other tools + +- `Interop` is the only place that knows the names of PHP CS Fixer and PHP_CodeSniffer. Two tables, one per + configuration format: `PhpCsFixer` holds the fixers (friendsofphp, the custom fixers of kubawerlos) and + `PhpCodeSniffer` the sniffs (the standards shipped with phpcs, slevomat). The two name spaces do not overlap, a fixer is snake_case and a sniff is + `Standard.Category.Name`, so `Translator` searches one merged table and a mixed configuration translates too. + The tables are given to its constructor, the two of the core when none are; `PluginRegistry` holds the + instance the run uses. +- A translation is the decisions a foreign rule stands for, or a closure of the foreign options setting them + (`Translation::set()`, `setAll()`, `setBlankLines()` for a count of blank lines, `setOrder()` for a list whose + sequence carries meaning, `prefer()` for a value another foreign rule may set, and `keep()` for what a foreign rule + turned off stands for, or what a foreign rule governs while allowing either form); it sets only the decisions its + options name, names by `cover()` those it sets under other options, which `findPaths()` lists as covered whatever + the defaults set (`Generic.PHP.ForbiddenFunctions` forbids nothing by default, its `sizeof` being an alias written + as `count`), and it warns about what it could not carry over instead of dropping it in silence. The foreign + configuration does not say whether the project has PHPStan, so a decision of a rule with `typesRequired` is left out + with a warning unless its value is `keep`, while `findPaths()` still lists it as covered. Every closure reads its + options through `??`, which is what lets the test call each of them with none and resolve the decisions they set + against the catalogue: a path that does not exist, or a value its domain does not take, fails there. +- Two foreign rules giving one decision a value equally firmly merge in either order: a list becomes the union unless + it is an order, or what both allow where it names what is allowed (`setAllowed()`), which a single value set inside + it narrows to that value, a map merges key by key, a name standing alone in it (`[print_r]`) joining the others + and giving way to an entry of the name, a boolean is true when either says so, a count of blank lines + widens to the range of both and says so; the places of the trailing comma and the counts of `blankLines`, split + across several foreign rules, depend on it. A value only preferred, or `keep`, gives way to one set, and values that + contradict each other leave the decision out with a warning. How far a name is written out the foreign tools decide + on two axes, the form of a qualified name (`setQualificationShape()`) and whether a global name stands bare + (`setQualificationFallback()`), which `Translation` puts together into the decisions of `qualification`. A foreign + rule turned off makes the requirements it stands for `keep`, unless another foreign rule the configuration may run + stands for them too. +- The tables also answer the reverse question, which decisions a foreign name stands for (`Translator::findPaths()`), + and that is what makes `phpcs:ignore ` work and what the reference and `dresscode catalogue` print as + "covers". A message of a sniff stands for the decisions of its own row and for nothing where it has none, the + decisions of the whole sniff silencing more than the message did; excluded while the configuration turns its sniff + on by name, it makes what it stands for `keep` all the same. A foreign name is not a valid key in the + configuration: `dresscode migrate config` translates a whole configuration file once, options included, the one named + or the one the project has as `Interop\FormatterSearch` finds it, a `pint.json` of Laravel Pint read as data, its + preset the standard of that name and its rules those of PHP CS Fixer, a file of PHP CS Fixer, which reading runs, only + after the user agrees in a terminal, and then in a process of its own after the autoload of its project + (`PhpCsFixer::readConfig()`), so that what it writes, the directory it changes and its exit stay its own; it writes + with the decisions the scope as init writes it, every path it leaves out that the formatter checked said as a change, + and the overrides keeping the decisions of a foreign rule where the + configuration leaves files out of it, into `dresscode.neon` or the file `--output` names in the root of the project, + the paths it names being relative to it, in the format of its extension, NEON or PHP, never over an existing one, + resolved as a run reads it and checked to say the decisions translated before it is written whole, or printed + instead with `--dry-run`, the first decision a foreign rule translated only in part carrying in a comment what was + not carried over (`Translation::translateRule()`), and a translation with any warning ending with exit code 1, + saying how many phpcs suppression comments the files of the scope hold for `dresscode migrate suppressions` to + rewrite, and such a name written as a key of the file, in `--only` or in `fixRisky` is refused with the decisions + that cover it. With `--format json` both commands write a document of their `version` and the `root` and nothing + else, `config` the configuration, the file it was written to, the counts, the warnings and what stays untranslated, + and never asking, `suppressions` the counts, the diffs of a dry run and what it kept, widened and skipped. diff --git a/docs/reference/api.md b/docs/reference/api.md index faadbbfc04..f7918a9d74 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -280,7 +280,7 @@ final readonly class Config extends DressCode\Profile final class Catalogue public const Version = 4 public const CoreSections = ['file', 'indentation', 'braces', 'blankLines', 'multiline', 'spacing', 'naming', 'casing', 'qualification', 'imports', 'classes', 'functions', 'types', 'controlFlow', 'expressions', 'literals', 'comments', 'phpdoc', 'hygiene', 'mistakes', 'upgrading'] - public const Lexicon = ['required' => 'always there', 'forbidden' => 'never there', 'optional' => null, 'none' => null, 'sameLine' => null, 'nextLine' => null, 'above' => null, 'lineStart' => null, 'lineEnd' => null, 'perLine' => null, 'fill' => null, 'apart' => null, 'attached' => null, 'closingApart' => null, 'aligned' => null, 'collapsed' => null, 'expanded' => null, 'first' => null, 'last' => null, 'lastStatementSetApart' => null, 'spaces' => null, 'tabs' => null, 'bare' => null, 'imported' => null, 'backslashed' => null, 'self' => null, 'declared' => null, 'lowercase' => null, 'uppercase' => null, 'canonical' => null, 'alphabetical' => null, 'short' => null, 'long' => null, 'separated' => null, 'combined' => null, 'grouped' => null, 'joined' => null, 'PascalCase' => null, 'camelCase' => null, 'snake_case' => null, 'UPPER_CASE' => null, 'Pascal_Snake_Case' => null, 'constant' => null, 'property' => null, 'traitUse' => null] + public const Lexicon = ['required' => 'always there', 'forbidden' => 'never there', 'optional' => null, 'none' => null, 'sameLine' => null, 'nextLine' => null, 'above' => null, 'lineStart' => null, 'lineEnd' => null, 'perLine' => null, 'fill' => null, 'apart' => null, 'attached' => null, 'closingApart' => null, 'aligned' => null, 'collapsed' => null, 'expanded' => null, 'first' => null, 'last' => null, 'lastStatementSetApart' => null, 'spaces' => null, 'tabs' => null, 'bare' => null, 'imported' => null, 'backslashed' => null, 'self' => null, 'declared' => null, 'lowercase' => null, 'uppercase' => null, 'canonical' => null, 'alphabetical' => null, 'short' => null, 'long' => null, 'separated' => null, 'combined' => null, 'grouped' => null, 'joined' => null, 'PascalCase' => null, 'camelCase' => null, 'snake_case' => null, 'UPPER_CASE' => null, 'Pascal_Snake_Case' => null, 'constant' => null, 'property' => null, 'traitUse' => null, 'reported' => 'reported where it stands'] public const ProjectSection = 'project' public static function fromRules(list> $rules): DressCode\Config\Catalogue public function find(string $path): ?DressCode\Decision @@ -302,7 +302,11 @@ final readonly class Layer public readonly ?string $name public readonly ?string $package public readonly ?string $decision - public function __construct(DressCode\Config\LayerKind $kind, ?string $name = null, ?string $package = null, ?string $decision = null) + public readonly ?string $library + public readonly ?string $since + public readonly ?string $from + public function __construct(DressCode\Config\LayerKind $kind, ?string $name = null, ?string $package = null, ?string $decision = null, ?string $library = null, ?string $since = null, ?string $from = null) + public function withSection(string $library, string $since, ?string $from): DressCode\Config\Layer public function describe(): string public function formatReference(): string public function isProject(): bool @@ -332,7 +336,7 @@ final class ConfigurationException extends Exception implements Stringable, Thro ```php final readonly class Application - public const Version = '1.3.2' + public const Version = '1.5.0' public function __construct(?resource $stdout = null, ?resource $stderr = null, ?resource $stdin = null, ?string $cwd = null, ?string $script = null, ?DressCode\Config $defaultConfig = null, ?bool $xdebug = null, ?bool $interactive = null) public function run(list $argv): int ``` @@ -673,7 +677,7 @@ final class RuleContext public readonly string $path public readonly DressCode\Style $style public readonly string $phpVersion - public function report(PhpSyntax\Node|PhpSyntax\Token $at, string $message, ?string $decision = null, ?PhpSyntax\Trivia $trivia = null, int $lineInTrivia = 0, ?DressCode\Risk $risk = null, ?PhpSyntax\Token $follows = null, bool $byLine = false, bool $fixable = true, ?string $because = null): bool + public function report(PhpSyntax\Node|PhpSyntax\Token $at, string $message, ?string $decision = null, ?PhpSyntax\Trivia $trivia = null, int $lineInTrivia = 0, ?DressCode\Risk $risk = null, ?PhpSyntax\Token $follows = null, bool $byLine = false, bool $fixable = true, ?string $because = null, ?DressCode\Config\Layer $source = null): bool public function isSilenced(PhpSyntax\Node|PhpSyntax\Token $at, ?string $decision = null, ?PhpSyntax\Trivia $trivia = null, int $lineInTrivia = 0): bool /** @template T of object */ public function getAnalysis(class-string $class): T @@ -778,6 +782,7 @@ final class TestFailure extends Exception implements Stringable, Throwable ```php final class UpgradingTester public static function collectProblems(string $file, string $root, list> $packageRules = []): list + public static function collectPackageProblems(string $root): list public static function runSample(string $code, string $root, array $decisions = [], string $name = 'sample'): DressCode\FileResult ``` @@ -862,6 +867,7 @@ final readonly class MemberMapEntry public readonly DressCode\Upgrading\MemberPattern $pattern public readonly T $value public readonly string $decision + public readonly ?DressCode\Config\Layer $origin ``` ## `DressCode\Upgrading\MemberMapGrammar` @@ -966,8 +972,8 @@ final readonly class Values public function find(string $path): ?DressCode\Value public function readMap(string $path): array public function findWithdrawnNames(string $path): list - public function findPackageEntries(string $path): array public function getEntryDecision(string $path, string $key): string + public function getEntryOrigin(string $path, string $key): ?DressCode\Config\Layer public function isKept(string $path): bool public function isSelected(string $path): bool ``` @@ -990,7 +996,8 @@ final readonly class Violation public readonly ?int $offset public readonly ?int $length public readonly ?array{int, int} $originalLines - public function __construct(string $decision, string $message, int $line, ?int $column, DressCode\Severity $severity, string $fingerprint, bool $fixable = true, ?DressCode\Risk $risk = null, bool $refused = false, ?string $because = null, ?string $derivedFrom = null, ?int $offset = null, ?int $length = null, ?array{int, int} $originalLines = null) + public readonly ?DressCode\Config\Layer $source + public function __construct(string $decision, string $message, int $line, ?int $column, DressCode\Severity $severity, string $fingerprint, bool $fixable = true, ?DressCode\Risk $risk = null, bool $refused = false, ?string $because = null, ?string $derivedFrom = null, ?int $offset = null, ?int $length = null, ?array{int, int} $originalLines = null, ?DressCode\Config\Layer $source = null) public function findSpanStart(string $text): ?array{int, int} public function findSpanEnd(string $text): ?array{int, int} public static function formatCode(string $code): string @@ -1118,6 +1125,7 @@ final readonly class Violation - `DressCode\Rules\Functions\FirstClassCallableForStringRule` - `DressCode\Rules\Functions\FunctionNameSpacingRule` - `DressCode\Rules\Functions\GetDebugTypeForTernaryRule` +- `DressCode\Rules\Functions\InvokeCallNotationRule` - `DressCode\Rules\Functions\JsonValidateForDecodeRule` - `DressCode\Rules\Functions\MethodCasingRule` - `DressCode\Rules\Functions\MultilineCallRule` @@ -1131,7 +1139,6 @@ final readonly class Violation - `DressCode\Rules\Functions\NoConversionFunctionsRule` - `DressCode\Rules\Functions\NoDebugOutputRule` - `DressCode\Rules\Functions\NoDirnameOfFileRule` -- `DressCode\Rules\Functions\NoExplicitInvokeCallsRule` - `DressCode\Rules\Functions\NoInnerFunctionsRule` - `DressCode\Rules\Functions\NoIsNullRule` - `DressCode\Rules\Functions\NoManualSubstringTestsRule` @@ -1204,6 +1211,7 @@ final readonly class Violation - `DressCode\Rules\Upgrading\ReplacedFunctionCallsRule` - `DressCode\Rules\Upgrading\ReplacedFunctionsRule` - `DressCode\Rules\Upgrading\ReplacedMembersRule` +- `DressCode\Rules\Upgrading\UnseenReferencesRule` - `DressCode\Rules\Variables\NoGlobalStatementsRule` - `DressCode\Rules\Variables\NoRepeatedAssignmentsRule` - `DressCode\Rules\Variables\NoSeparateIssetsRule` diff --git a/docs/reference/api.php b/docs/reference/api.php index 4ec8d3e25d..3a10fc59d1 100644 --- a/docs/reference/api.php +++ b/docs/reference/api.php @@ -322,7 +322,7 @@ function formatTemplates(string|false $comment, ReflectionClass $class): array $src = (string) realpath(__DIR__ . '/../../src'); foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $file) { - if ($file->getExtension() === 'php') { + if (preg_match('~^(\p{Lu}\w*|enums|exceptions)\.php$~', $file->getFilename())) { // a script there declares nothing require_once (string) $file; } } diff --git a/docs/reference/decisions.md b/docs/reference/decisions.md index ebac17b89e..d0addcbfdf 100644 --- a/docs/reference/decisions.md +++ b/docs/reference/decisions.md @@ -1,6 +1,6 @@ # Decisions -Generated by `composer reference` from the catalogue; do not edit. 443 decisions in 22 sections. +Generated by `composer reference` from the catalogue; do not edit. 445 decisions in 22 sections. A decision writing a construct of a newer PHP takes effect where every version of the target has it, the target being the oldest version the code runs on; one about a deprecation reports and fixes whatever the target, the code meeting the deprecation wherever it runs on the version that made it. @@ -47,7 +47,7 @@ meeting the deprecation wherever it runs on the version that made it. **mistakes** (24): [consecutivePhpdocs](#mistakesconsecutivephpdocs), [continueInSwitch](#mistakescontinueinswitch), [countOfUncountable](#mistakescountofuncountable), [debugOutput](#mistakesdebugoutput), [debugOutputFunctions](#mistakesdebugoutputfunctions), [errorSuppression](#mistakeserrorsuppression), [exceptionWhereThrowableBelongs](#mistakesexceptionwherethrowablebelongs), [finalParent](#mistakesfinalparent), [globalStatement](#mistakesglobalstatement), [invisibleCharacter](#mistakesinvisiblecharacter), [looseNumberComparison](#mistakesloosenumbercomparison), [neverReturnType.anonymousFunction](#mistakesneverreturntypeanonymousfunction), [neverReturnType.function](#mistakesneverreturntypefunction), [neverReturnType.method](#mistakesneverreturntypemethod), [readonlyProperty](#mistakesreadonlyproperty), [repeatedAssignment](#mistakesrepeatedassignment), [staticSetState](#mistakesstaticsetstate), [strictArgument](#mistakesstrictargument), [thisOutsideObject](#mistakesthisoutsideobject), [trailingWhitespaceInString](#mistakestrailingwhitespaceinstring), [unimplementedAbstractMethod](#mistakesunimplementedabstractmethod), [unreachableCatch](#mistakesunreachablecatch), [__debugInfoReturningNull](#mistakes__debuginforeturningnull), [__serialize](#mistakes__serialize) -**upgrading** (56): [api.attributeForAnnotation](#upgradingapiattributeforannotation), [api.attributeForMember](#upgradingapiattributeformember), [api.forbiddenClasses](#upgradingapiforbiddenclasses), [api.forbiddenConstants](#upgradingapiforbiddenconstants), [api.forbiddenFunctions](#upgradingapiforbiddenfunctions), [api.forbiddenMembers](#upgradingapiforbiddenmembers), [api.replacedCalls](#upgradingapireplacedcalls), [api.replacedClasses](#upgradingapireplacedclasses), [api.replacedFunctions](#upgradingapireplacedfunctions), [api.replacedMembers](#upgradingapireplacedmembers), [classes.Override](#upgradingclassesoverride), [classes.SensitiveParameter](#upgradingclassessensitiveparameter), [classes.SensitiveParameterNames](#upgradingclassessensitiveparameternames), [classes.Stringable](#upgradingclassesstringable), [declarations.deprecatedClass](#upgradingdeclarationsdeprecatedclass), [declarations.deprecatedFunction](#upgradingdeclarationsdeprecatedfunction), [declarations.deprecatedMember](#upgradingdeclarationsdeprecatedmember), [functions.arrayFirstLast](#upgradingfunctionsarrayfirstlast), [functions.arraySearchFunctions](#upgradingfunctionsarraysearchfunctions), [functions.array_is_list](#upgradingfunctionsarray_is_list), [functions.clamp](#upgradingfunctionsclamp), [functions.get_debug_type](#upgradingfunctionsget_debug_type), [functions.json_validate](#upgradingfunctionsjson_validate), [functions.roundingMode](#upgradingfunctionsroundingmode), [functions.substringFunctions](#upgradingfunctionssubstringfunctions), [packages](#upgradingpackages), [php.backticks](#upgradingphpbackticks), [php.caseSemicolon](#upgradingphpcasesemicolon), [php.constructorReturnValue](#upgradingphpconstructorreturnvalue), [php.csvEscapeArgument](#upgradingphpcsvescapeargument), [php.deprecatedApi](#upgradingphpdeprecatedapi), [php.deprecatedArgument](#upgradingphpdeprecatedargument), [php.dollarBraceInterpolation](#upgradingphpdollarbraceinterpolation), [php.dynamicProperty](#upgradingphpdynamicproperty), [php.floatArrayKey](#upgradingphpfloatarraykey), [php.implicitNullable](#upgradingphpimplicitnullable), [php.nullArgument](#upgradingphpnullargument), [php.nullArrayKey](#upgradingphpnullarraykey), [php.reservedNames](#upgradingphpreservednames), [php.returnInFinally](#upgradingphpreturninfinally), [php.sleepAndWakeup](#upgradingphpsleepandwakeup), [phpdoc.deprecated](#upgradingphpdocdeprecated), [phpdoc.readonly](#upgradingphpdocreadonly), [syntax.cloneWith](#upgradingsyntaxclonewith), [syntax.dynamicClassConstant](#upgradingsyntaxdynamicclassconstant), [syntax.firstClassCallables](#upgradingsyntaxfirstclasscallables), [syntax.match](#upgradingsyntaxmatch), [syntax.newInInitializer](#upgradingsyntaxnewininitializer), [syntax.nullsafe](#upgradingsyntaxnullsafe), [syntax.octalPrefix](#upgradingsyntaxoctalprefix), [syntax.pipe](#upgradingsyntaxpipe), [syntax.pipeMinCalls](#upgradingsyntaxpipemincalls), [syntax.promotedProperties](#upgradingsyntaxpromotedproperties), [syntax.readonlyClasses](#upgradingsyntaxreadonlyclasses), [syntax.spreadForArrayMerge](#upgradingsyntaxspreadforarraymerge), [syntax.throwExpression](#upgradingsyntaxthrowexpression) +**upgrading** (58): [api.attributeForAnnotation](#upgradingapiattributeforannotation), [api.attributeForMember](#upgradingapiattributeformember), [api.forbiddenClasses](#upgradingapiforbiddenclasses), [api.forbiddenConstants](#upgradingapiforbiddenconstants), [api.forbiddenFunctions](#upgradingapiforbiddenfunctions), [api.forbiddenMembers](#upgradingapiforbiddenmembers), [api.replacedCalls](#upgradingapireplacedcalls), [api.replacedClasses](#upgradingapireplacedclasses), [api.replacedFunctions](#upgradingapireplacedfunctions), [api.replacedMembers](#upgradingapireplacedmembers), [classes.Override](#upgradingclassesoverride), [classes.SensitiveParameter](#upgradingclassessensitiveparameter), [classes.SensitiveParameterNames](#upgradingclassessensitiveparameternames), [classes.Stringable](#upgradingclassesstringable), [declarations.deprecatedClass](#upgradingdeclarationsdeprecatedclass), [declarations.deprecatedFunction](#upgradingdeclarationsdeprecatedfunction), [declarations.deprecatedMember](#upgradingdeclarationsdeprecatedmember), [functions.arrayFirstLast](#upgradingfunctionsarrayfirstlast), [functions.arraySearchFunctions](#upgradingfunctionsarraysearchfunctions), [functions.array_is_list](#upgradingfunctionsarray_is_list), [functions.clamp](#upgradingfunctionsclamp), [functions.get_debug_type](#upgradingfunctionsget_debug_type), [functions.json_validate](#upgradingfunctionsjson_validate), [functions.roundingMode](#upgradingfunctionsroundingmode), [functions.substringFunctions](#upgradingfunctionssubstringfunctions), [packages](#upgradingpackages), [php.backticks](#upgradingphpbackticks), [php.caseSemicolon](#upgradingphpcasesemicolon), [php.constructorReturnValue](#upgradingphpconstructorreturnvalue), [php.csvEscapeArgument](#upgradingphpcsvescapeargument), [php.deprecatedApi](#upgradingphpdeprecatedapi), [php.deprecatedArgument](#upgradingphpdeprecatedargument), [php.dollarBraceInterpolation](#upgradingphpdollarbraceinterpolation), [php.dynamicProperty](#upgradingphpdynamicproperty), [php.floatArrayKey](#upgradingphpfloatarraykey), [php.implicitNullable](#upgradingphpimplicitnullable), [php.nullArgument](#upgradingphpnullargument), [php.nullArrayKey](#upgradingphpnullarraykey), [php.reservedNames](#upgradingphpreservednames), [php.returnInFinally](#upgradingphpreturninfinally), [php.sleepAndWakeup](#upgradingphpsleepandwakeup), [phpdoc.deprecated](#upgradingphpdocdeprecated), [phpdoc.readonly](#upgradingphpdocreadonly), [syntax.cloneWith](#upgradingsyntaxclonewith), [syntax.dynamicClassConstant](#upgradingsyntaxdynamicclassconstant), [syntax.firstClassCallables](#upgradingsyntaxfirstclasscallables), [syntax.match](#upgradingsyntaxmatch), [syntax.newInInitializer](#upgradingsyntaxnewininitializer), [syntax.nullsafe](#upgradingsyntaxnullsafe), [syntax.octalPrefix](#upgradingsyntaxoctalprefix), [syntax.pipe](#upgradingsyntaxpipe), [syntax.pipeMinCalls](#upgradingsyntaxpipemincalls), [syntax.promotedProperties](#upgradingsyntaxpromotedproperties), [syntax.readonlyClasses](#upgradingsyntaxreadonlyclasses), [syntax.spreadForArrayMerge](#upgradingsyntaxspreadforarraymerge), [syntax.throwExpression](#upgradingsyntaxthrowexpression), [unseen.classNameString](#upgradingunseenclassnamestring), [unseen.dynamicAccess](#upgradingunseendynamicaccess) **namespaces** (2): [constants](#namespacesconstants), [functions](#namespacesfunctions) @@ -75,8 +75,9 @@ A byte order mark at the start of the file. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Files\NoBomRule`, stage Structure +- Covers: `encoding`, `Generic.Files.ByteOrderMark` ### file.closingTagAtEnd @@ -84,8 +85,9 @@ A `?>` ending a file of PHP only; a `?>` before HTML is content. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Files\NoClosingTagRule`, stage Structure +- Covers: `no_closing_tag`, `PSR2.Files.ClosingTag` ### file.finalLineEndings @@ -93,8 +95,9 @@ Exactly one line ending after the last line; a file ending in HTML is content. - By default `keep` - Takes: exactly 1; `keep` -- The standards: perCs `1`, psr12 `1`, nette `1` +- The standards: perCs `1`, psr12 `1`, nette `1`, symfony `1`, laravel `1` - Rule `DressCode\Rules\Files\FinalLineEndingsRule`, stage Formatting +- Covers: `single_blank_line_at_eof`, `PSR2.Files.EndFileNewline` ### file.lineEnding @@ -102,8 +105,9 @@ The line ending of every line, which the code written new takes too; under `keep - By default `keep` - Takes: `LF` (every line ends with LF); `CRLF` (every line ends with CRLF); `majority` (every line ends as most lines of the file do, LF on a tie); `keep` -- The standards: perCs `majority`, psr12 `majority`, nette `majority` +- The standards: perCs `majority`, psr12 `majority`, nette `majority`, symfony `majority`, laravel `majority` - Rule `DressCode\Rules\Files\LineEndingRule`, stage Finishing +- Covers: `line_ending`, `Generic.Files.LineEndings` ### file.lineLength @@ -111,8 +115,9 @@ The widest line, by which what spreads over lines is split. - By default `none` - Takes: a count from 1; `none` (no line is too wide) -- The standards: perCs `120`, psr12 `120`, nette `140` +- The standards: perCs `120`, psr12 `120`, nette `140`, symfony `none`, laravel `none` - Rule `DressCode\Rules\Files\LineLengthRule`, stage Finishing +- Covers: `Generic.Files.LineLength`, `SlevomatCodingStandard.Classes.RequireMultiLineMethodSignature`, `SlevomatCodingStandard.ControlStructures.RequireMultiLineCondition`, `SlevomatCodingStandard.Files.LineLength` ### file.lineLengthExcept @@ -120,7 +125,7 @@ What a line too wide leaves as it is: a construct named is not spread over lines - By default `[imports]` - Takes: a list of `imports` (a `use` import other than a group use on one line, which cannot be split); `strings` (a line that would fit were each of its string literals an empty pair of quotes, a string being never split); `argument` (the arguments of a call, of `new` and of an attribute, the variables of `unset()` and `isset()`); `parameter` (the parameters of a function or a method); `closureUse` (the variables of the `use` of a closure); `attribute` (the attributes of one group); `condition` (a condition of `if`, `elseif`, `while` and `do-while`); `chain` (a chain of method calls); `ternary` (a ternary); `for` (the header of a `for`); `logical` (the operands of a boolean operator outside a condition); `concatenation` (the operands of `.`); `arithmetic` (the operands of `+`, `-`, `*`, `/`, `%` and `**`); `bitwise` (the operands of `|`, `&`, `^`, `<<` and `>>`); `coalesce` (the operands of `??`); `pipe` (the operands of `|>`); `array` (the items of an array, which `multiline.maxWidth.array` spreads all the same); `list` (the items of `list()`); `implements` (the interfaces a class implements or an interface extends); `matchCondition` (the conditions of a `match` arm); regular expressions -- The standards: perCs `[imports]`, psr12 `[imports]`, nette `[imports, strings]` +- The standards: perCs `[imports]`, psr12 `[imports]`, nette `[imports, strings]`, symfony `[imports]`, laravel `[imports]` - Rule `DressCode\Rules\Files\LineLengthRule`, stage Finishing - Read by rule `DressCode\Rules\Arrays\MultilineArrayRule`, which this decision does not turn on - Read by rule `DressCode\Rules\Classes\MultilineImplementsRule`, which this decision does not turn on @@ -134,6 +139,7 @@ What a line too wide leaves as it is: a construct named is not spread over lines - Read by rule `DressCode\Rules\Functions\MultilineClosureUseRule`, which this decision does not turn on - Read by rule `DressCode\Rules\Functions\MultilineSignatureRule`, which this decision does not turn on - Read by rule `DressCode\Rules\Whitespace\MultilineAttributeRule`, which this decision does not turn on +- Covers: `Generic.Files.LineLength`, `SlevomatCodingStandard.Files.LineLength` ### file.longLines @@ -141,8 +147,9 @@ A line wider than `file.lineLength` that nothing could split is reported, unless - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Files\LineLengthRule`, stage Finishing +- Covers: `Generic.Files.LineLength`, `SlevomatCodingStandard.Files.LineLength` ### file.openingTag @@ -150,8 +157,9 @@ A line wider than `file.lineLength` that nothing could split is reported, unless - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `required`, psr12 `required`, nette `required` +- The standards: perCs `required`, psr12 `required`, nette `required`, symfony `required`, laravel `required` - Rule `DressCode\Rules\Files\OpeningTagNotationRule`, stage Structure +- Covers: `full_opening_tag`, `Generic.PHP.DisallowShortOpenTag` ### file.statementsPerLine @@ -159,8 +167,9 @@ A line wider than `file.lineLength` that nothing could split is reported, unless - By default `keep` - Takes: exactly 1; `keep` -- The standards: perCs `1`, psr12 `1`, nette `1` +- The standards: perCs `1`, psr12 `1`, nette `1`, symfony `1`, laravel `1` - Rule `DressCode\Rules\Whitespace\NoStatementsSharingLineRule`, stage Formatting +- Covers: `no_multiple_statements_per_line`, `Generic.Formatting.DisallowMultipleStatements`, `PSR12.Classes.ClosingBrace`, `PSR2.ControlStructures.SwitchDeclaration` ### file.strictTypes.declaration @@ -168,8 +177,9 @@ Every file of PHP code declares `strict_types=1` as its first statement, a file - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Files\StrictTypesRequiredRule`, stage Structure +- Covers: `declare_strict_types`, `Generic.PHP.RequireStrictTypes`, `SlevomatCodingStandard.TypeHints.DeclareStrictTypes` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### file.strictTypes.position @@ -178,8 +188,9 @@ Where the declaration of `strict_types` stands. - By default `keep` - Takes: `sameLine` (`` of an array item begin - By default `keep` - Takes: `sameLine` (on the line of what comes before); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\BinaryOperatorSpacingRule`, stage Formatting ### multiline.expressionStart.arrowFunction @@ -942,7 +1012,7 @@ Where an expression spanning several lines after the `=>` of an arrow function b - By default `keep` - Takes: `sameLine` (on the line of what comes before); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\BinaryOperatorSpacingRule`, stage Formatting ### multiline.expressionStart.assignment @@ -951,7 +1021,7 @@ Where an expression spanning several lines after the `=` of an assignment, a con - By default `keep` - Takes: `sameLine` (on the line of what comes before); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\BinaryOperatorSpacingRule`, stage Formatting ### multiline.expressionStart.echo @@ -960,7 +1030,7 @@ Where an expression spanning several lines after `echo` and `print` begins. - By default `keep` - Takes: `sameLine` (on the line of what comes before); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `sameLine` +- The standards: perCs `keep`, psr12 `keep`, nette `sameLine`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting ### multiline.expressionStart.return @@ -969,7 +1039,7 @@ Where an expression spanning several lines after `return` begins. - By default `keep` - Takes: `sameLine` (on the line of what comes before); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `sameLine` +- The standards: perCs `keep`, psr12 `keep`, nette `sameLine`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting ### multiline.expressionStart.throw @@ -978,7 +1048,7 @@ Where an expression spanning several lines after `throw` begins. - By default `keep` - Takes: `sameLine` (on the line of what comes before); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `sameLine` +- The standards: perCs `keep`, psr12 `keep`, nette `sameLine`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting ### multiline.expressionStart.yield @@ -987,7 +1057,7 @@ Where an expression spanning several lines after `yield` and `yield from` begins - By default `keep` - Takes: `sameLine` (on the line of what comes before); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `sameLine` +- The standards: perCs `keep`, psr12 `keep`, nette `sameLine`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting ### multiline.fillMinItems.array @@ -996,7 +1066,7 @@ From how many items without keys an array written in `fill` fills its lines. - By default `2` - Takes: a count from 2 -- The standards: perCs `2`, psr12 `2`, nette `6` +- The standards: perCs `2`, psr12 `2`, nette `6`, symfony `2`, laravel `2` - Rule `DressCode\Rules\Arrays\MultilineArrayRule`, stage Formatting ### multiline.fillMinItems.import @@ -1005,7 +1075,7 @@ From how many names a group use written in `fill` fills its lines. - By default `2` - Takes: a count from 2 -- The standards: perCs `2`, psr12 `2`, nette `2` +- The standards: perCs `2`, psr12 `2`, nette `2`, symfony `2`, laravel `2` - Rule `DressCode\Rules\Namespaces\MultilineImportRule`, stage Formatting ### multiline.fillMinItems.list @@ -1014,7 +1084,7 @@ From how many items without keys `list()` written in `fill` fills its lines. - By default `2` - Takes: a count from 2 -- The standards: perCs `2`, psr12 `2`, nette `6` +- The standards: perCs `2`, psr12 `2`, nette `6`, symfony `2`, laravel `2` - Rule `DressCode\Rules\Arrays\MultilineArrayRule`, stage Formatting ### multiline.fillMinItems.matchCondition @@ -1023,7 +1093,7 @@ From how many conditions an arm written in `fill` fills its lines. - By default `2` - Takes: a count from 2 -- The standards: perCs `2`, psr12 `2`, nette `6` +- The standards: perCs `2`, psr12 `2`, nette `6`, symfony `2`, laravel `2` - Rule `DressCode\Rules\Expressions\MultilineMatchArmRule`, stage Formatting ### multiline.items.argument @@ -1034,8 +1104,9 @@ A sole argument that is a string, a message, is not spread for its width - By default `keep` - Takes: `perLine` (every argument on a line of its own); `fill` (the arguments may share lines, one on a line passing too, and one spanning lines stands on lines of its own); `fillPlain` (as `fill` where every argument is a variable, a constant, a literal or a property, joined where they fit on one line and no comment stands among them, as `perLine` otherwise); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `perLine`, psr12 `perLine`, nette `fillPlain` +- The standards: perCs `perLine`, psr12 `perLine`, nette `fillPlain`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\MultilineCallRule`, stage Formatting +- Covers: `method_argument_space`, `PSR2.Methods.FunctionCallSignature` ### multiline.items.arithmetic @@ -1045,7 +1116,7 @@ Not about the operators of one place joined by another, which begin a family of - By default `keep` - Takes: `perLine` (every operand on a line of its own); `fill` (the operands may share lines, one on a line passing too, and one spanning lines may share them as well); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `[perLine, fill]`, psr12 `[perLine, fill]`, nette `[perLine, fill]` +- The standards: perCs `[perLine, fill]`, psr12 `[perLine, fill]`, nette `[perLine, fill]`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting ### multiline.items.array @@ -1056,7 +1127,7 @@ Written as `fill`, the items without keys fill the lines evenly from `multiline. - By default `keep` - Takes: `perLine` (every item on a line of its own); `fill` (the items may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `perLine`, psr12 `keep`, nette `fill` +- The standards: perCs `perLine`, psr12 `keep`, nette `fill`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Arrays\MultilineArrayRule`, stage Formatting ### multiline.items.attribute @@ -1065,7 +1136,7 @@ The attributes of one group spread over lines by their author or for their width - By default `keep` - Takes: `perLine` (every attribute on a line of its own); `fill` (the attributes may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Whitespace\MultilineAttributeRule`, stage Formatting ### multiline.items.bitwise @@ -1076,7 +1147,7 @@ Not about the operators of one place joined by another, which begin a family of - By default `keep` - Takes: `perLine` (every operand on a line of its own); `fill` (the operands may share lines, one on a line passing too, and one spanning lines may share them as well); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `[perLine, fill]`, psr12 `[perLine, fill]`, nette `[perLine, fill]` +- The standards: perCs `[perLine, fill]`, psr12 `[perLine, fill]`, nette `[perLine, fill]`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting ### multiline.items.chain @@ -1085,7 +1156,7 @@ The links of a chain of method calls and property accesses spread over lines by - By default `keep` - Takes: `perLine` (every link on a line of its own); `fill` (the links may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `perLine`, psr12 `keep`, nette `perLine` +- The standards: perCs `perLine`, psr12 `keep`, nette `perLine`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineChainRule`, stage Formatting ### multiline.items.closureUse @@ -1094,7 +1165,7 @@ The variables of the `use` of a closure spread over lines by their author or for - By default `keep` - Takes: `perLine` (every variable on a line of its own); `fill` (the variables may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\MultilineClosureUseRule`, stage Formatting ### multiline.items.coalesce @@ -1105,7 +1176,7 @@ Not about the operators of one place joined by another, which begin a family of - By default `keep` - Takes: `perLine` (every operand on a line of its own); `fill` (the operands may share lines, one on a line passing too, and one spanning lines may share them as well); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting ### multiline.items.concatenation @@ -1116,7 +1187,7 @@ Not about the operators of one place joined by another, which begin a family of - By default `keep` - Takes: `perLine` (every operand on a line of its own); `fill` (the operands may share lines, one on a line passing too, and one spanning lines may share them as well); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `[perLine, fill]`, psr12 `[perLine, fill]`, nette `[perLine, fill]` +- The standards: perCs `[perLine, fill]`, psr12 `[perLine, fill]`, nette `[perLine, fill]`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting ### multiline.items.condition @@ -1125,8 +1196,9 @@ A condition of `if`, `elseif`, `while` and `do-while` joined by boolean operator - By default `keep` - Takes: `perLine` (every part on a line of its own); `fill` (the parts may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `[perLine, fill]`, psr12 `[perLine, fill]`, nette `[perLine, fill]` +- The standards: perCs `[perLine, fill]`, psr12 `[perLine, fill]`, nette `[perLine, fill]`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\MultilineConditionRule`, stage Formatting +- Covers: `PSR12.ControlStructures.ControlStructureSpacing`, `SlevomatCodingStandard.ControlStructures.RequireMultiLineCondition` ### multiline.items.for @@ -1134,7 +1206,7 @@ The sections of the header of a `for` spread over lines by its author or for the - By default `keep` - Takes: `perLine` (every section on a line of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `perLine`, psr12 `perLine`, nette `perLine` +- The standards: perCs `perLine`, psr12 `perLine`, nette `perLine`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\MultilineForRule`, stage Formatting ### multiline.items.implements @@ -1143,8 +1215,9 @@ The interfaces a class or an enum implements, or an interface extends, spread ov - By default `keep` - Takes: `perLine` (every interface on a line of its own); `fill` (the interfaces may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `perLine`, psr12 `perLine`, nette `perLine` +- The standards: perCs `perLine`, psr12 `perLine`, nette `perLine`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\MultilineImplementsRule`, stage Formatting +- Covers: `PSR12.Classes.AnonClassDeclaration`, `PSR2.Classes.ClassDeclaration` ### multiline.items.import @@ -1154,7 +1227,7 @@ Written as `fill`, the names fill the lines evenly from `multiline.fillMinItems. - By default `keep` - Takes: `perLine` (every name on a line of its own); `fill` (the names may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `fill` +- The standards: perCs `keep`, psr12 `keep`, nette `fill`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Namespaces\MultilineImportRule`, stage Formatting ### multiline.items.list @@ -1165,7 +1238,7 @@ Written as `fill`, the items without keys fill the lines evenly from `multiline. - By default `keep` - Takes: `perLine` (every item on a line of its own); `fill` (the items may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `perLine`, psr12 `keep`, nette `fill` +- The standards: perCs `perLine`, psr12 `keep`, nette `fill`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Arrays\MultilineArrayRule`, stage Formatting ### multiline.items.logical @@ -1176,7 +1249,7 @@ Not about the operators of one place joined by another, which begin a family of - By default `keep` - Takes: `perLine` (every operand on a line of its own); `fill` (the operands may share lines, one on a line passing too, and one spanning lines may share them as well); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `[perLine, fill]`, psr12 `[perLine, fill]`, nette `[perLine, fill]` +- The standards: perCs `[perLine, fill]`, psr12 `[perLine, fill]`, nette `[perLine, fill]`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting ### multiline.items.matchArm @@ -1187,7 +1260,7 @@ Not about the conditions of one arm, which `multiline.items.matchCondition` spre - By default `keep` - Takes: `perLine` (every arm on a line of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `perLine` +- The standards: perCs `keep`, psr12 `keep`, nette `perLine`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineMatchRule`, stage Formatting ### multiline.items.matchCondition @@ -1198,7 +1271,7 @@ Written as `fill`, the conditions fill the lines evenly from `multiline.fillMinI - By default `keep` - Takes: `perLine` (every condition on a line of its own); `fill` (the conditions may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `perLine`, psr12 `perLine`, nette `fill` +- The standards: perCs `perLine`, psr12 `perLine`, nette `fill`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineMatchArmRule`, stage Formatting ### multiline.items.parameter @@ -1207,8 +1280,9 @@ The parameters of a function or a method spread over lines by their author or fo - By default `keep` - Takes: `perLine` (every parameter on a line of its own); `fill` (the parameters may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `perLine`, psr12 `perLine`, nette `perLine` +- The standards: perCs `perLine`, psr12 `perLine`, nette `perLine`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\MultilineSignatureRule`, stage Formatting +- Covers: `SlevomatCodingStandard.Classes.RequireMultiLineMethodSignature`, `Squiz.Functions.MultiLineFunctionDeclaration` ### multiline.items.pipe @@ -1218,7 +1292,7 @@ Not about the operators of one place joined by another, which begin a family of - By default `keep` - Takes: `perLine` (every operand on a line of its own); `fill` (the operands may share lines, one on a line passing too, and one spanning lines may share them as well); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting ### multiline.items.ternary @@ -1227,8 +1301,9 @@ A ternary spread over lines by its author, or for the width of its line. - By default `keep` - Takes: `perLine` (every operand on a line of its own); `fill` (the operands may share lines, one on a line passing too, and one spanning lines stands on lines of its own); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `perLine`, psr12 `keep`, nette `perLine` +- The standards: perCs `perLine`, psr12 `keep`, nette `perLine`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineTernaryRule`, stage Formatting +- Covers: `SlevomatCodingStandard.ControlStructures.RequireMultiLineTernaryOperator` ### multiline.maxWidth.array @@ -1236,7 +1311,7 @@ The width from bracket to bracket over which an array of several items written o - By default `none` - Takes: a count from 1; `none` (never spread for its width) -- The standards: perCs `none`, psr12 `none`, nette `130` +- The standards: perCs `none`, psr12 `none`, nette `130`, symfony `none`, laravel `none` - Rule `DressCode\Rules\Arrays\MultilineArrayRule`, stage Formatting ### multiline.operatorPosition.arithmetic @@ -1245,8 +1320,9 @@ Where `+`, `-`, `*`, `/`, `%` and `**` stand at a line break. - By default `keep` - Takes: `lineStart` (one ending a line opens the next, unless a comment follows it); `lineEnd` (one opening a line ends the one before, unless a comment precedes it); `keep` -- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart` +- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting +- Covers: `operator_linebreak` ### multiline.operatorPosition.bitwise @@ -1254,8 +1330,9 @@ Where `|`, `&`, `^`, `<<` and `>>` stand at a line break. - By default `keep` - Takes: `lineStart` (one ending a line opens the next, unless a comment follows it); `lineEnd` (one opening a line ends the one before, unless a comment precedes it); `keep` -- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart` +- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting +- Covers: `operator_linebreak` ### multiline.operatorPosition.coalesce @@ -1263,8 +1340,9 @@ Where `??` stands at a line break. - By default `keep` - Takes: `lineStart` (one ending a line opens the next, unless a comment follows it); `lineEnd` (one opening a line ends the one before, unless a comment precedes it); `keep` -- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart` +- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting +- Covers: `operator_linebreak` ### multiline.operatorPosition.comparison @@ -1272,9 +1350,10 @@ Where a comparison stands at a line break: one ending its line moves only where - By default `keep` - Takes: `lineStart` (one ending a line opens the next, unless a comment follows it); `lineEnd` (one opening a line ends the one before, unless a comment precedes it); `keep` -- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart` +- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\BinaryOperatorSpacingRule`, stage Formatting - Read by rule `DressCode\Rules\ControlFlow\MultilineConditionRule`, which this decision does not turn on +- Covers: `operator_linebreak` ### multiline.operatorPosition.concatenation @@ -1282,8 +1361,9 @@ Where `.` stands at a line break. - By default `keep` - Takes: `lineStart` (one ending a line opens the next, unless a comment follows it); `lineEnd` (one opening a line ends the one before, unless a comment precedes it); `keep` -- The standards: perCs `lineStart`, psr12 `keep`, nette `lineStart` +- The standards: perCs `lineStart`, psr12 `keep`, nette `lineStart`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting +- Covers: `operator_linebreak` ### multiline.operatorPosition.condition @@ -1291,8 +1371,9 @@ Where a boolean operator chaining a condition of `if`, `elseif`, `while` and `do - By default `keep` - Takes: `lineStart` (one ending a line opens the next, unless a comment follows it); `lineEnd` (one opening a line ends the one before, unless a comment precedes it); `keep` -- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart` +- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart`, symfony `lineStart`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\MultilineConditionRule`, stage Formatting +- Covers: `operator_linebreak`, `PSR12.ControlStructures.BooleanOperatorPlacement` ### multiline.operatorPosition.logical @@ -1300,8 +1381,9 @@ Where `&&`, `||`, `and`, `or` and `xor` outside a condition stand at a line brea - By default `keep` - Takes: `lineStart` (one ending a line opens the next, unless a comment follows it); `lineEnd` (one opening a line ends the one before, unless a comment precedes it); `keep` -- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart` +- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart`, symfony `lineStart`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting +- Covers: `operator_linebreak` ### multiline.operatorPosition.pipe @@ -1309,8 +1391,9 @@ Where `|>` stands at a line break. - By default `keep` - Takes: `lineStart` (one ending a line opens the next, unless a comment follows it); `lineEnd` (one opening a line ends the one before, unless a comment precedes it); `keep` -- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart` +- The standards: perCs `lineStart`, psr12 `lineStart`, nette `lineStart`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\MultilineBinaryOperatorRule`, stage Formatting +- Covers: `operator_linebreak` ### multiline.operatorPosition.ternary @@ -1318,9 +1401,10 @@ Where `?`, `:` and `?:` of a ternary stand at a line break, of a ternary spread - By default `keep` - Takes: `lineStart` (one ending a line opens the next, unless a comment follows it); `lineEnd` (one opening a line ends the one before, unless a comment precedes it); `keep` -- The standards: perCs `lineStart`, psr12 `keep`, nette `lineStart` +- The standards: perCs `lineStart`, psr12 `keep`, nette `lineStart`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\TernaryOperatorSpacingRule`, stage Formatting - Read by rule `DressCode\Rules\Expressions\MultilineTernaryRule`, which this decision does not turn on +- Covers: `operator_linebreak`, `SlevomatCodingStandard.ControlStructures.DisallowTrailingMultiLineTernaryOperator` ### multiline.semicolon @@ -1328,8 +1412,9 @@ Where the semicolon of a statement spanning several lines stands. - By default `keep` - Takes: `sameLine` (closing the last line of the statement); `nextLine` (on a line of its own below a chain of method calls spread over lines, closing the last line of any other statement); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `sameLine` +- The standards: perCs `keep`, psr12 `keep`, nette `sameLine`, symfony `keep`, laravel `sameLine` - Rule `DressCode\Rules\Whitespace\SemicolonSpacingRule`, stage Formatting +- Covers: `multiline_whitespace_before_semicolons`, `Squiz.WhiteSpace.SemicolonSpacing` ### multiline.spreadAlways @@ -1337,8 +1422,9 @@ What is spread over lines whatever its width. - By default `[]` - Takes: a list of `promotedProperty` (the parameters of a signature declaring a promoted property) -- The standards: perCs `[]`, psr12 `[]`, nette `[promotedProperty]` +- The standards: perCs `[]`, psr12 `[]`, nette `[promotedProperty]`, symfony `[]`, laravel `[]` - Rule `DressCode\Rules\Functions\MultilineSignatureRule`, stage Formatting +- Covers: `multiline_promoted_properties`, `SlevomatCodingStandard.Classes.RequireMultiLineMethodSignature` ### multiline.spreadItem @@ -1346,7 +1432,7 @@ What a call too wide spreads where an argument could spread in its place; one so - By default `never` - Takes: `never` (the call spreads its arguments); `sole` (a sole argument that is a call, `new` or an array spreads in the parentheses, `foo(bar(` and `))` together); `last` (as `sole`, and so does the last argument after those before it where the line fits up to its bracket, `foo($a, [` and `])` together) -- The standards: perCs `never`, psr12 `never`, nette `sole` +- The standards: perCs `never`, psr12 `never`, nette `sole`, symfony `never`, laravel `never` - Rule `DressCode\Rules\Functions\MultilineCallRule`, stage Formatting ### multiline.trailingComma.argument @@ -1355,8 +1441,9 @@ The trailing comma of the multi-line arguments of a call and the variables of `i - By default `keep` - Takes: `required` (always there where the closing bracket stands on its own line, never where it follows the last item); `optional` (as the author wrote it where the closing bracket stands on its own line, never where it follows the last item); `keep` -- The standards: perCs `required`, psr12 `keep`, nette `required` +- The standards: perCs `required`, psr12 `keep`, nette `required`, symfony `optional`, laravel `optional` - Rule `DressCode\Rules\Arrays\TrailingCommaRule`, stage Structure +- Covers: `no_trailing_comma_in_singleline`, `SlevomatCodingStandard.Functions.RequireTrailingCommaInCall` ### multiline.trailingComma.array @@ -1364,8 +1451,9 @@ The trailing comma of a multi-line array, a destructuring written with `[...]` i - By default `keep` - Takes: `required` (always there where the closing bracket stands on its own line, never where it follows the last item); `optional` (as the author wrote it where the closing bracket stands on its own line, never where it follows the last item); `keep` -- The standards: perCs `required`, psr12 `keep`, nette `required` +- The standards: perCs `required`, psr12 `keep`, nette `required`, symfony `required`, laravel `required` - Rule `DressCode\Rules\Arrays\TrailingCommaRule`, stage Structure +- Covers: `no_trailing_comma_in_singleline`, `trailing_comma_in_multiline`, `SlevomatCodingStandard.Arrays.TrailingArrayComma` ### multiline.trailingComma.closureUse @@ -1373,7 +1461,7 @@ The trailing comma of a multi-line `use` of a closure; any value but `keep` also - By default `keep` - Takes: `required` (always there where the closing bracket stands on its own line, never where it follows the last item); `optional` (as the author wrote it where the closing bracket stands on its own line, never where it follows the last item); `keep` -- The standards: perCs `required`, psr12 `keep`, nette `required` +- The standards: perCs `required`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Arrays\TrailingCommaRule`, stage Structure ### multiline.trailingComma.import @@ -1382,8 +1470,9 @@ The trailing comma of the names of a multi-line group use; any value but `keep` - By default `keep` - Takes: `required` (always there where the closing bracket stands on its own line, never where it follows the last item); `optional` (as the author wrote it where the closing bracket stands on its own line, never where it follows the last item); `keep` -- The standards: perCs `optional`, psr12 `keep`, nette `required` +- The standards: perCs `optional`, psr12 `keep`, nette `required`, symfony `optional`, laravel `optional` - Rule `DressCode\Rules\Arrays\TrailingCommaRule`, stage Structure +- Covers: `no_trailing_comma_in_singleline` ### multiline.trailingComma.list @@ -1391,8 +1480,9 @@ The trailing comma of a multi-line `list()`; any value but `keep` also removes t - By default `keep` - Takes: `required` (always there where the closing bracket stands on its own line, never where it follows the last item); `optional` (as the author wrote it where the closing bracket stands on its own line, never where it follows the last item); `keep` -- The standards: perCs `optional`, psr12 `keep`, nette `required` +- The standards: perCs `optional`, psr12 `keep`, nette `required`, symfony `optional`, laravel `optional` - Rule `DressCode\Rules\Arrays\TrailingCommaRule`, stage Structure +- Covers: `no_trailing_comma_in_singleline` ### multiline.trailingComma.matchArm @@ -1400,7 +1490,7 @@ The trailing comma of the arms of a multi-line `match`; any value but `keep` als - By default `keep` - Takes: `required` (always there where the closing bracket stands on its own line, never where it follows the last item); `optional` (as the author wrote it where the closing bracket stands on its own line, never where it follows the last item); `keep` -- The standards: perCs `required`, psr12 `keep`, nette `required` +- The standards: perCs `required`, psr12 `keep`, nette `required`, symfony `required`, laravel `keep` - Rule `DressCode\Rules\Arrays\TrailingCommaRule`, stage Structure ### multiline.trailingComma.parameter @@ -1409,8 +1499,9 @@ The trailing comma of a multi-line list of parameters; any value but `keep` also - By default `keep` - Takes: `required` (always there where the closing bracket stands on its own line, never where it follows the last item); `optional` (as the author wrote it where the closing bracket stands on its own line, never where it follows the last item); `keep` -- The standards: perCs `required`, psr12 `keep`, nette `required` +- The standards: perCs `required`, psr12 `keep`, nette `required`, symfony `required`, laravel `optional` - Rule `DressCode\Rules\Arrays\TrailingCommaRule`, stage Structure +- Covers: `function_declaration`, `SlevomatCodingStandard.Functions.RequireTrailingCommaInDeclaration` ## spacing @@ -1420,8 +1511,9 @@ The whitespace wider than a single space around an operator, aligning a column o - By default `spaces` - Takes: `forbidden` (never there, more whitespace collapsing to a single space); `spaces` (spaces aligning a column staying, tabs not); `tabs` (tabs aligning a column staying, spaces not); `optional` (there or not, as the author wrote it) -- The standards: perCs `spaces`, psr12 `spaces`, nette `spaces` +- The standards: perCs `spaces`, psr12 `spaces`, nette `spaces`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Expressions\BinaryOperatorSpacingRule`, stage Formatting +- Covers: `binary_operator_spaces`, `PSR12.Operators.OperatorSpacing`, `Squiz.WhiteSpace.LogicalOperatorSpacing`, `Squiz.WhiteSpace.OperatorSpacing` ### spacing.alignment.comma @@ -1429,8 +1521,9 @@ The whitespace wider than a single space after a comma, aligning the columns of - By default `tabs` - Takes: `forbidden` (never there, more whitespace collapsing to a single space); `spaces` (spaces aligning a column staying, tabs not); `tabs` (tabs aligning a column staying, spaces not); `optional` (there or not, as the author wrote it) -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `tabs` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `tabs`, symfony `optional`, laravel `optional` - Rule `DressCode\Rules\Whitespace\CommaSpacingRule`, stage Formatting +- Covers: `no_whitespace_before_comma_in_array`, `whitespace_after_comma_in_array`, `Generic.Functions.FunctionCallArgumentSpacing`, `Squiz.Functions.FunctionDeclarationArgumentSpacing` ### spacing.alignment.comment @@ -1438,7 +1531,7 @@ The whitespace wider than a single space before a comment following code, aligni - By default `optional` - Takes: `forbidden` (never there, more whitespace collapsing to a single space); `optional` (there or not, as the author wrote it) -- The standards: perCs `optional`, psr12 `optional`, nette `optional` +- The standards: perCs `optional`, psr12 `optional`, nette `optional`, symfony `optional`, laravel `optional` - Rule `DressCode\Rules\Comments\CommentSpacingRule`, stage Finishing - Modifies comments @@ -1448,8 +1541,9 @@ The whitespace wider than a single space around `?` and `:`, aligning a column; - By default `optional` - Takes: `forbidden` (never there, more whitespace collapsing to a single space); `optional` (there or not, as the author wrote it) -- The standards: perCs `optional`, psr12 `optional`, nette `forbidden` +- The standards: perCs `optional`, psr12 `optional`, nette `forbidden`, symfony `optional`, laravel `optional` - Rule `DressCode\Rules\Expressions\TernaryOperatorSpacingRule`, stage Formatting +- Covers: `ternary_operator_spaces`, `PSR12.Operators.OperatorSpacing` ### spacing.alternativeSyntax @@ -1457,7 +1551,7 @@ The space before the colon of the alternative syntax and of a label, and the sin - By default `keep` - Takes: `'if ($a):'` (no space before the colon); `'if ($a) :'` (a single space before the colon); `keep` -- The standards: perCs `'if ($a):'`, psr12 `'if ($a):'`, nette `'if ($a):'` +- The standards: perCs `'if ($a):'`, psr12 `'if ($a):'`, nette `'if ($a):'`, symfony `'if ($a):'`, laravel `keep` - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting ### spacing.anonymousClass @@ -1466,8 +1560,9 @@ The space between `class` and the arguments of an anonymous class. - By default `keep` - Takes: `'new class ($a)'` (a single space before the arguments); `'new class($a)'` (no space before the arguments); `keep` -- The standards: perCs `'new class ($a)'`, psr12 `'new class ($a)'`, nette `'new class ($a)'` +- The standards: perCs `'new class ($a)'`, psr12 `'new class ($a)'`, nette `'new class ($a)'`, symfony `'new class($a)'`, laravel `'new class($a)'` - Rule `DressCode\Rules\Classes\ClassHeadSpacingRule`, stage Formatting +- Covers: `class_definition`, `PSR12.Classes.AnonClassDeclaration` ### spacing.anonymousFunction @@ -1475,8 +1570,9 @@ The space between the `function` of a closure and its parenthesis. - By default `keep` - Takes: `'function($x)'` (no space before the parenthesis); `'function ($x)'` (a single space); `keep` -- The standards: perCs `'function ($x)'`, psr12 `'function ($x)'`, nette `'function ($x)'` +- The standards: perCs `'function ($x)'`, psr12 `'function ($x)'`, nette `'function ($x)'`, symfony `'function ($x)'`, laravel `'function ($x)'` - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting +- Covers: `function_declaration`, `Squiz.Functions.MultiLineFunctionDeclaration` ### spacing.arrowFunction @@ -1484,8 +1580,9 @@ The space between `fn` and its parenthesis. - By default `keep` - Takes: `'fn($x) => $x'` (no space before the parenthesis); `'fn ($x) => $x'` (a single space); `keep` -- The standards: perCs `'fn($x) => $x'`, psr12 `keep`, nette `'fn($x) => $x'` +- The standards: perCs `'fn($x) => $x'`, psr12 `keep`, nette `'fn($x) => $x'`, symfony `'fn ($x) => $x'`, laravel `'fn ($x) => $x'` - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting +- Covers: `function_declaration`, `SlevomatCodingStandard.Functions.ArrowFunctionDeclaration` ### spacing.attribute @@ -1493,8 +1590,9 @@ The whitespace inside the brackets of an attribute group and before the parenthe - By default `keep` - Takes: `'#[Foo($a)]'` (none inside the brackets, none before the parenthesis); `keep` -- The standards: perCs `'#[Foo($a)]'`, psr12 `keep`, nette `'#[Foo($a)]'` +- The standards: perCs `'#[Foo($a)]'`, psr12 `keep`, nette `'#[Foo($a)]'`, symfony `'#[Foo($a)]'`, laravel `keep` - Rule `DressCode\Rules\Whitespace\AttributeSpacingRule`, stage Formatting +- Covers: `attribute_block_no_spaces` ### spacing.binaryOperator @@ -1502,8 +1600,9 @@ The spaces around a binary operator, an assignment, `instanceof`, `=>` and the ` - By default `keep` - Takes: `'$a + $b'` (a single space around); `keep` -- The standards: perCs `'$a + $b'`, psr12 `'$a + $b'`, nette `'$a + $b'` +- The standards: perCs `'$a + $b'`, psr12 `'$a + $b'`, nette `'$a + $b'`, symfony `'$a + $b'`, laravel `'$a + $b'` - Rule `DressCode\Rules\Expressions\BinaryOperatorSpacingRule`, stage Formatting +- Covers: `binary_operator_spaces`, `PSR12.Operators.OperatorSpacing`, `Squiz.WhiteSpace.LogicalOperatorSpacing`, `Squiz.WhiteSpace.OperatorSpacing` ### spacing.body @@ -1511,7 +1610,7 @@ The space inside the braces of a body whose code stands on their line, of a func - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `required`, psr12 `required`, nette `required` +- The standards: perCs `required`, psr12 `required`, nette `required`, symfony `required`, laravel `keep` - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting ### spacing.call @@ -1520,8 +1619,9 @@ The whitespace between the name of a function and its parenthesis, of a call and - By default `keep` - Takes: `'foo($a, $b)'` (no space before the parenthesis); `keep` -- The standards: perCs `'foo($a, $b)'`, psr12 `'foo($a, $b)'`, nette `'foo($a, $b)'` +- The standards: perCs `'foo($a, $b)'`, psr12 `'foo($a, $b)'`, nette `'foo($a, $b)'`, symfony `'foo($a, $b)'`, laravel `'foo($a, $b)'` - Rule `DressCode\Rules\Functions\FunctionNameSpacingRule`, stage Formatting +- Covers: `function_declaration`, `no_spaces_after_function_name`, `Generic.Functions.FunctionCallArgumentSpacing.SpaceBeforeOpenBracket`, `PSR2.Methods.FunctionCallSignature`, `Squiz.Functions.FunctionDeclaration` ### spacing.cast @@ -1529,8 +1629,9 @@ The space between a cast and its operand, which stays on the line of the cast. - By default `keep` - Takes: `'(int) $x'` (a single space after the cast); `'(int)$x'` (no space after the cast); `keep` -- The standards: perCs `'(int) $x'`, psr12 `'(int) $x'`, nette `'(int) $x'` +- The standards: perCs `'(int) $x'`, psr12 `'(int) $x'`, nette `'(int) $x'`, symfony `'(int) $x'`, laravel `'(int) $x'` - Rule `DressCode\Rules\Expressions\OperandSpacingRule`, stage Formatting +- Covers: `cast_spaces` ### spacing.catchType @@ -1538,7 +1639,7 @@ The space around the bar between the types of a `catch`. - By default `keep` - Takes: `'catch (A | B $e)'` (a single space around the bar); `'catch (A|B $e)'` (no space around the bar); `keep` -- The standards: perCs `'catch (A|B $e)'`, psr12 `'catch (A | B $e)'`, nette `'catch (A|B $e)'` +- The standards: perCs `'catch (A|B $e)'`, psr12 `'catch (A | B $e)'`, nette `'catch (A|B $e)'`, symfony `'catch (A|B $e)'`, laravel `'catch (A|B $e)'` - Rule `DressCode\Rules\Types\TypeDeclarationSpacingRule`, stage Formatting ### spacing.comma @@ -1547,8 +1648,9 @@ The whitespace around a comma, which stays on the line of what is before it, a l - By default `keep` - Takes: `'$a, $b'` (none before, a single space after); `keep` -- The standards: perCs `'$a, $b'`, psr12 `'$a, $b'`, nette `'$a, $b'` +- The standards: perCs `'$a, $b'`, psr12 `'$a, $b'`, nette `'$a, $b'`, symfony `'$a, $b'`, laravel `'$a, $b'` - Rule `DressCode\Rules\Whitespace\CommaSpacingRule`, stage Formatting +- Covers: `no_whitespace_before_comma_in_array`, `whitespace_after_comma_in_array`, `Generic.Functions.FunctionCallArgumentSpacing`, `Squiz.Functions.FunctionDeclarationArgumentSpacing` ### spacing.comment.afterCode @@ -1556,7 +1658,7 @@ The whitespace before a comment that follows code on its line. - By default `keep` - Takes: `'$a; // text'` (at least one space); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `'$a; // text'` +- The standards: perCs `keep`, psr12 `keep`, nette `'$a; // text'`, symfony `'$a; // text'`, laravel `keep` - Rule `DressCode\Rules\Comments\CommentSpacingRule`, stage Finishing - Modifies comments @@ -1566,8 +1668,9 @@ The space after the marker of a comment and before the closing one, unless anoth - By default `keep` - Takes: `'// text'` (a single space after the marker); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `'// text'` +- The standards: perCs `keep`, psr12 `keep`, nette `'// text'`, symfony `'// text'`, laravel `'// text'` - Rule `DressCode\Rules\Comments\CommentSpacingRule`, stage Finishing +- Covers: `PhpCsFixerCustomFixers/comment_surrounded_by_spaces`, `single_line_comment_spacing` - Modifies comments ### spacing.concatenation @@ -1576,8 +1679,9 @@ The space around the concatenation operator, unless it sits at a line break. - By default `keep` - Takes: `'$a . $b'` (a single space around); `'$a.$b'` (no space around); `keep` -- The standards: perCs `'$a . $b'`, psr12 `'$a . $b'`, nette `'$a . $b'` +- The standards: perCs `'$a . $b'`, psr12 `'$a . $b'`, nette `'$a . $b'`, symfony `'$a.$b'`, laravel `'$a.$b'` - Rule `DressCode\Rules\Expressions\BinaryOperatorSpacingRule`, stage Formatting +- Covers: `concat_space`, `PSR12.Operators.OperatorSpacing`, `Squiz.Strings.ConcatenationSpacing` ### spacing.declare @@ -1585,8 +1689,9 @@ The whitespace inside a `declare` statement. - By default `keep` - Takes: `'declare(strict_types=1)'` (none inside); `keep` -- The standards: perCs `'declare(strict_types=1)'`, psr12 `'declare(strict_types=1)'`, nette `'declare(strict_types=1)'` +- The standards: perCs `'declare(strict_types=1)'`, psr12 `'declare(strict_types=1)'`, nette `'declare(strict_types=1)'`, symfony `'declare(strict_types=1)'`, laravel `'declare(strict_types=1)'` - Rule `DressCode\Rules\Files\DeclareSpacingRule`, stage Formatting +- Covers: `declare_equal_normalize`, `declare_parentheses`, `PSR12.Files.DeclareStatement` ### spacing.doubleColon @@ -1594,8 +1699,9 @@ The whitespace around the double colon. - By default `keep` - Takes: `'Foo::bar()'` (none around); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `'Foo::bar()'` +- The standards: perCs `keep`, psr12 `keep`, nette `'Foo::bar()'`, symfony `keep`, laravel `'Foo::bar()'` - Rule `DressCode\Rules\Expressions\MemberAccessSpacingRule`, stage Formatting +- Covers: `no_space_around_double_colon`, `Squiz.WhiteSpace.ScopeKeywordSpacing` ### spacing.import @@ -1603,7 +1709,7 @@ The space around the braces of a group use, which hug the names. - By default `keep` - Takes: `'use Acme\{Foo, Bar};'` (no space around the braces); `keep` -- The standards: perCs `'use Acme\{Foo, Bar};'`, psr12 `'use Acme\{Foo, Bar};'`, nette `'use Acme\{Foo, Bar};'` +- The standards: perCs `'use Acme\{Foo, Bar};'`, psr12 `'use Acme\{Foo, Bar};'`, nette `'use Acme\{Foo, Bar};'`, symfony `'use Acme\{Foo, Bar};'`, laravel `keep` - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting ### spacing.keyword @@ -1612,9 +1718,10 @@ The space after a keyword followed by code on its line, `return`, `new`, `if`, ` - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `required`, psr12 `required`, nette `required` +- The standards: perCs `required`, psr12 `required`, nette `required`, symfony `required`, laravel `required` - Rule `DressCode\Rules\Classes\ClassHeadSpacingRule`, stage Formatting - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting +- Covers: `function_declaration`, `single_space_around_construct`, `Generic.WhiteSpace.LanguageConstructSpacing`, `PSR12.Classes.AnonClassDeclaration`, `PSR12.Traits.UseDeclaration`, `PSR2.Classes.ClassDeclaration`, `PSR2.Classes.ClassDeclaration.SpaceBeforeKeyword`, `PSR2.ControlStructures.SwitchDeclaration`, `Squiz.ControlStructures.ControlSignature`, `Squiz.ControlStructures.ForEachLoopDeclaration`, `Squiz.Functions.FunctionDeclaration` ### spacing.logicalNot @@ -1622,8 +1729,9 @@ The whitespace between `!` and its operand, which stay on one line. - By default `keep` - Takes: `'!$x'` (no space after the operator); `'! $x'` (a single space after the operator); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `['!$x', '! $x']`, psr12 `['!$x', '! $x']`, nette `'!$x'` +- The standards: perCs `['!$x', '! $x']`, psr12 `['!$x', '! $x']`, nette `'!$x'`, symfony `'!$x'`, laravel `'! $x'` - Rule `DressCode\Rules\Expressions\OperandSpacingRule`, stage Formatting +- Covers: `not_operator_with_successor_space`, `not_operator_without_successor_space`, `unary_operator_spaces`, `SlevomatCodingStandard.Operators.NegationOperatorSpacing`, `Squiz.WhiteSpace.OperatorSpacing.Unary` ### spacing.namedArgument @@ -1631,8 +1739,9 @@ The whitespace around the colon of a named argument. - By default `keep` - Takes: `'foo(name: $x)'` (none before the colon, a single space after); `keep` -- The standards: perCs `'foo(name: $x)'`, psr12 `keep`, nette `'foo(name: $x)'` +- The standards: perCs `'foo(name: $x)'`, psr12 `keep`, nette `'foo(name: $x)'`, symfony `'foo(name: $x)'`, laravel `keep` - Rule `DressCode\Rules\Functions\NamedArgumentSpacingRule`, stage Formatting +- Covers: `SlevomatCodingStandard.Functions.NamedArgumentSpacing` ### spacing.objectOperator @@ -1640,8 +1749,9 @@ The whitespace around the object operator. - By default `keep` - Takes: `'$a->b()'` (none around); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `'$a->b()'` +- The standards: perCs `keep`, psr12 `keep`, nette `'$a->b()'`, symfony `'$a->b()'`, laravel `'$a->b()'` - Rule `DressCode\Rules\Expressions\MemberAccessSpacingRule`, stage Formatting +- Covers: `object_operator_without_whitespace`, `Squiz.WhiteSpace.ObjectOperatorSpacing` ### spacing.openBrace @@ -1649,8 +1759,9 @@ The space before the brace or the body a structure opens on the line of its head - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `required`, psr12 `required`, nette `required` +- The standards: perCs `required`, psr12 `required`, nette `required`, symfony `required`, laravel `required` - Rule `DressCode\Rules\Whitespace\ConstructSpacingRule`, stage Formatting +- Covers: `Squiz.ControlStructures.ControlSignature` ### spacing.padding.argument @@ -1658,8 +1769,9 @@ The whitespace inside the parentheses of the arguments of a call, of `new` and o - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Whitespace\DelimiterSpacingRule`, stage Formatting +- Covers: `function_declaration`, `no_spaces_inside_parenthesis`, `spaces_inside_parentheses`, `PSR12.Classes.AnonClassDeclaration`, `PSR12.ControlStructures.ControlStructureSpacing`, `PSR2.ControlStructures.ControlStructureSpacing`, `PSR2.Methods.FunctionCallSignature`, `Squiz.ControlStructures.ForEachLoopDeclaration`, `Squiz.ControlStructures.ForLoopDeclaration` ### spacing.padding.array @@ -1667,8 +1779,9 @@ The whitespace inside the brackets or the parentheses of an array. - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Whitespace\DelimiterSpacingRule`, stage Formatting +- Covers: `trim_array_spaces`, `SlevomatCodingStandard.Arrays.SingleLineArrayWhitespace` ### spacing.padding.closureUse @@ -1676,8 +1789,9 @@ The whitespace inside the parentheses of the `use` of a closure. - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Whitespace\DelimiterSpacingRule`, stage Formatting +- Covers: `function_declaration`, `no_spaces_inside_parenthesis`, `spaces_inside_parentheses`, `PSR12.Classes.AnonClassDeclaration`, `PSR12.ControlStructures.ControlStructureSpacing`, `PSR2.ControlStructures.ControlStructureSpacing`, `PSR2.Methods.FunctionCallSignature`, `Squiz.ControlStructures.ForEachLoopDeclaration`, `Squiz.ControlStructures.ForLoopDeclaration` ### spacing.padding.controlStructure @@ -1685,8 +1799,9 @@ The whitespace inside the parentheses of `if`, `elseif`, `while`, `for`, `foreac - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Whitespace\DelimiterSpacingRule`, stage Formatting +- Covers: `function_declaration`, `no_spaces_inside_parenthesis`, `spaces_inside_parentheses`, `PSR12.Classes.AnonClassDeclaration`, `PSR12.ControlStructures.ControlStructureSpacing`, `PSR2.ControlStructures.ControlStructureSpacing`, `PSR2.Methods.FunctionCallSignature`, `Squiz.ControlStructures.ForEachLoopDeclaration`, `Squiz.ControlStructures.ForLoopDeclaration`, `Squiz.WhiteSpace.ControlStructureSpacing` ### spacing.padding.grouping @@ -1694,8 +1809,9 @@ The whitespace inside the parentheses grouping an expression, `($a + $b) * $c`. - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Whitespace\DelimiterSpacingRule`, stage Formatting +- Covers: `function_declaration`, `no_spaces_inside_parenthesis`, `spaces_inside_parentheses`, `PSR12.Classes.AnonClassDeclaration`, `PSR12.ControlStructures.ControlStructureSpacing`, `PSR2.ControlStructures.ControlStructureSpacing`, `PSR2.Methods.FunctionCallSignature`, `Squiz.ControlStructures.ForEachLoopDeclaration`, `Squiz.ControlStructures.ForLoopDeclaration` ### spacing.padding.list @@ -1703,8 +1819,9 @@ The whitespace inside the brackets or the parentheses of a destructuring. - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Whitespace\DelimiterSpacingRule`, stage Formatting +- Covers: `trim_array_spaces`, `SlevomatCodingStandard.Arrays.SingleLineArrayWhitespace` ### spacing.padding.offset @@ -1712,8 +1829,9 @@ The whitespace inside the brackets of an offset access and before them. - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `exceptLiterals` (a single space around an index other than a string or an integer literal, `$a[ $i ]` but `$a[0]`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Whitespace\DelimiterSpacingRule`, stage Formatting +- Covers: `no_spaces_around_offset`, `Squiz.Arrays.ArrayBracketSpacing` ### spacing.padding.parameter @@ -1721,8 +1839,9 @@ The whitespace inside the parentheses of the parameters of a function, a method, - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Whitespace\DelimiterSpacingRule`, stage Formatting +- Covers: `function_declaration`, `no_spaces_inside_parenthesis`, `spaces_inside_parentheses`, `PSR12.Classes.AnonClassDeclaration`, `PSR12.ControlStructures.ControlStructureSpacing`, `PSR2.ControlStructures.ControlStructureSpacing`, `PSR2.Methods.FunctionCallSignature`, `Squiz.ControlStructures.ForEachLoopDeclaration`, `Squiz.ControlStructures.ForLoopDeclaration` ### spacing.reference @@ -1730,8 +1849,9 @@ The whitespace between `&` and its operand. - By default `keep` - Takes: `'&$x'` (none between); `keep` -- The standards: perCs `'&$x'`, psr12 `'&$x'`, nette `'&$x'` +- The standards: perCs `'&$x'`, psr12 `'&$x'`, nette `'&$x'`, symfony `'&$x'`, laravel `keep` - Rule `DressCode\Rules\Expressions\OperandSpacingRule`, stage Formatting +- Covers: `SlevomatCodingStandard.PHP.ReferenceSpacing` ### spacing.semicolon.after @@ -1739,8 +1859,9 @@ The whitespace after a semicolon that more code follows on its line. - By default `keep` - Takes: `'for ($i = 0; $i < 5; $i++)'` (a single space after a semicolon); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `'for ($i = 0; $i < 5; $i++)'` +- The standards: perCs `keep`, psr12 `keep`, nette `'for ($i = 0; $i < 5; $i++)'`, symfony `'for ($i = 0; $i < 5; $i++)'`, laravel `'for ($i = 0; $i < 5; $i++)'` - Rule `DressCode\Rules\Whitespace\SemicolonSpacingRule`, stage Formatting +- Covers: `space_after_semicolon`, `Squiz.ControlStructures.ForLoopDeclaration` ### spacing.semicolon.before @@ -1748,8 +1869,9 @@ The whitespace before a semicolon, which stays on the line of what it ends. - By default `keep` - Takes: `'$a;'` (no space before a semicolon); `keep` -- The standards: perCs `'$a;'`, psr12 `keep`, nette `'$a;'` +- The standards: perCs `'$a;'`, psr12 `keep`, nette `'$a;'`, symfony `'$a;'`, laravel `'$a;'` - Rule `DressCode\Rules\Whitespace\SemicolonSpacingRule`, stage Formatting +- Covers: `no_singleline_whitespace_before_semicolons`, `PSR12.Traits.UseDeclaration`, `Squiz.ControlStructures.ForLoopDeclaration`, `Squiz.WhiteSpace.SemicolonSpacing` ### spacing.spread @@ -1757,8 +1879,9 @@ The whitespace between `...` and its operand. - By default `keep` - Takes: `'...$x'` (none between); `keep` -- The standards: perCs `'...$x'`, psr12 `'...$x'`, nette `'...$x'` +- The standards: perCs `'...$x'`, psr12 `'...$x'`, nette `'...$x'`, symfony `'...$x'`, laravel `keep` - Rule `DressCode\Rules\Expressions\OperandSpacingRule`, stage Formatting +- Covers: `SlevomatCodingStandard.Operators.SpreadOperatorSpacing` ### spacing.switchCase @@ -1766,8 +1889,9 @@ The whitespace before the colon of a `case`. - By default `keep` - Takes: `'case 1:'` (none before the colon); `keep` -- The standards: perCs `'case 1:'`, psr12 `'case 1:'`, nette `'case 1:'` +- The standards: perCs `'case 1:'`, psr12 `'case 1:'`, nette `'case 1:'`, symfony `'case 1:'`, laravel `'case 1:'` - Rule `DressCode\Rules\ControlFlow\SwitchCaseSpacingRule`, stage Formatting +- Covers: `switch_case_space`, `PSR2.ControlStructures.SwitchDeclaration`, `PSR2.ControlStructures.SwitchDeclaration.SpaceBeforeColonCASE` ### spacing.ternary @@ -1775,8 +1899,9 @@ The whitespace around `?` and `:` of a ternary, and around `?:` as a whole, unle - By default `keep` - Takes: `'$a ? $b : $c'` (whitespace around `?` and `:`); `keep` -- The standards: perCs `'$a ? $b : $c'`, psr12 `'$a ? $b : $c'`, nette `'$a ? $b : $c'` +- The standards: perCs `'$a ? $b : $c'`, psr12 `'$a ? $b : $c'`, nette `'$a ? $b : $c'`, symfony `'$a ? $b : $c'`, laravel `'$a ? $b : $c'` - Rule `DressCode\Rules\Expressions\TernaryOperatorSpacingRule`, stage Formatting +- Covers: `ternary_operator_spaces`, `PSR12.Operators.OperatorSpacing` ### spacing.typeDeclaration @@ -1784,8 +1909,9 @@ The whitespace of a type declaration, which stays on one line with what it descr - By default `keep` - Takes: `'?int $x'` (none inside a type, a single space before the name); `keep` -- The standards: perCs `'?int $x'`, psr12 `'?int $x'`, nette `'?int $x'` +- The standards: perCs `'?int $x'`, psr12 `'?int $x'`, nette `'?int $x'`, symfony `'?int $x'`, laravel `'?int $x'` - Rule `DressCode\Rules\Types\TypeDeclarationSpacingRule`, stage Formatting +- Covers: `compact_nullable_type_declaration`, `return_type_declaration`, `type_declaration_spaces`, `types_spaces`, `PSR12.Functions.NullableTypeDeclaration`, `PSR12.Functions.ReturnTypeDeclaration`, `SlevomatCodingStandard.Classes.BackedEnumTypeSpacing`, `SlevomatCodingStandard.TypeHints.ParameterTypeHintSpacing`, `SlevomatCodingStandard.TypeHints.ReturnTypeHintSpacing` ### spacing.unaryOperator @@ -1793,8 +1919,9 @@ The whitespace between a unary operator other than `!` and its operand, which st - By default `keep` - Takes: `'-$x'` (no space after the operator); `keep` -- The standards: perCs `'-$x'`, psr12 `'-$x'`, nette `'-$x'` +- The standards: perCs `'-$x'`, psr12 `'-$x'`, nette `'-$x'`, symfony `'-$x'`, laravel `'-$x'` - Rule `DressCode\Rules\Expressions\OperandSpacingRule`, stage Formatting +- Covers: `unary_operator_spaces`, `Generic.WhiteSpace.IncrementDecrementSpacing`, `Squiz.WhiteSpace.OperatorSpacing.Unary` ## naming @@ -1804,8 +1931,9 @@ The word `Abstract` in front of the name of an abstract class, `AbstractFoo`, wh - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ClassKindInNameRule`, stage Structure +- Covers: `SlevomatCodingStandard.Classes.SuperfluousAbstractClassNaming` ### naming.class @@ -1813,8 +1941,9 @@ The case of the name of a class, an interface, a trait and an enum, which is rep - By default `keep` - Takes: `PascalCase` (a capital first and a lowercase letter somewhere, `FooBar`, a name of two letters such as `IO` passing); `camelCase` (a lowercase letter first, `fooBar`); `UPPER_CASE` (capitals, digits and underscores, `FOO_BAR`); `snake_case` (lowercase letters, digits and underscores, `foo_bar`); `Pascal_Snake_Case` (words joined by underscores, each beginning with a capital, `WP_REST_Request`, a PascalCase name passing too); `keep` -- The standards: perCs `PascalCase`, psr12 `PascalCase`, nette `PascalCase` +- The standards: perCs `PascalCase`, psr12 `PascalCase`, nette `PascalCase`, symfony `PascalCase`, laravel `keep` - Rule `DressCode\Rules\Classes\NameCasingRule`, stage Structure +- Covers: `Squiz.Classes.ValidClassName` ### naming.constant @@ -1822,8 +1951,9 @@ The case of the name of a class constant and a `const` outside a class, a consta - By default `keep` - Takes: `PascalCase` (a capital first and a lowercase letter somewhere, `FooBar`, a name of two letters such as `IO` passing); `camelCase` (a lowercase letter first, `fooBar`); `UPPER_CASE` (capitals, digits and underscores, `FOO_BAR`); `snake_case` (lowercase letters, digits and underscores, `foo_bar`); `Pascal_Snake_Case` (words joined by underscores, each beginning with a capital, `WP_REST_Request`, a PascalCase name passing too); `keep` -- The standards: perCs `UPPER_CASE`, psr12 `UPPER_CASE`, nette `PascalCase` +- The standards: perCs `UPPER_CASE`, psr12 `UPPER_CASE`, nette `PascalCase`, symfony `UPPER_CASE`, laravel `keep` - Rule `DressCode\Rules\Classes\NameCasingRule`, stage Structure +- Covers: `Generic.NamingConventions.UpperCaseConstantName`, `Generic.NamingConventions.UpperCaseConstantName.ClassConstantNotUpperCase` ### naming.enumCase @@ -1831,7 +1961,7 @@ The case of the name of a case of an enum, which is reported and never renamed. - By default `keep` - Takes: `PascalCase` (a capital first and a lowercase letter somewhere, `FooBar`, a name of two letters such as `IO` passing); `camelCase` (a lowercase letter first, `fooBar`); `UPPER_CASE` (capitals, digits and underscores, `FOO_BAR`); `snake_case` (lowercase letters, digits and underscores, `foo_bar`); `Pascal_Snake_Case` (words joined by underscores, each beginning with a capital, `WP_REST_Request`, a PascalCase name passing too); `keep` -- The standards: perCs `PascalCase`, psr12 `keep`, nette `PascalCase` +- The standards: perCs `PascalCase`, psr12 `keep`, nette `PascalCase`, symfony `PascalCase`, laravel `keep` - Rule `DressCode\Rules\Classes\NameCasingRule`, stage Structure ### naming.errorAffix @@ -1840,8 +1970,9 @@ The word `Error` at the end of the name of a class that is not abstract, `FooErr - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ClassKindInNameRule`, stage Structure +- Covers: `SlevomatCodingStandard.Classes.SuperfluousErrorNaming` ### naming.except @@ -1849,7 +1980,7 @@ The patterns of names never reported, whatever their kind, as the methods of a s - By default `[]` - Takes: a list of regular expressions -- The standards: perCs `[]`, psr12 `[]`, nette `[]` +- The standards: perCs `[]`, psr12 `[]`, nette `[]`, symfony `[]`, laravel `[]` - Rule `DressCode\Rules\Classes\NameCasingRule`, stage Structure ### naming.exceptionAffix @@ -1858,8 +1989,9 @@ The word `Exception` at the end of the name of an exception, `FooException`, whi - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ClassKindInNameRule`, stage Structure +- Covers: `SlevomatCodingStandard.Classes.SuperfluousExceptionNaming` ### naming.function @@ -1867,8 +1999,9 @@ The case of the name of a function, those beginning with `__` being PHP's, which - By default `keep` - Takes: `PascalCase` (a capital first and a lowercase letter somewhere, `FooBar`, a name of two letters such as `IO` passing); `camelCase` (a lowercase letter first, `fooBar`); `UPPER_CASE` (capitals, digits and underscores, `FOO_BAR`); `snake_case` (lowercase letters, digits and underscores, `foo_bar`); `Pascal_Snake_Case` (words joined by underscores, each beginning with a capital, `WP_REST_Request`, a PascalCase name passing too); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `camelCase` +- The standards: perCs `keep`, psr12 `keep`, nette `camelCase`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\NameCasingRule`, stage Structure +- Covers: `Generic.NamingConventions.CamelCapsFunctionName`, `Squiz.NamingConventions.ValidFunctionName` ### naming.interfaceAffix @@ -1876,8 +2009,9 @@ The word `Interface` at the end of the name of an interface, `FooInterface`, whi - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ClassKindInNameRule`, stage Structure +- Covers: `SlevomatCodingStandard.Classes.SuperfluousInterfaceNaming` ### naming.method @@ -1885,8 +2019,9 @@ The case of the name of a method, those beginning with `__` being PHP's, which i - By default `keep` - Takes: `PascalCase` (a capital first and a lowercase letter somewhere, `FooBar`, a name of two letters such as `IO` passing); `camelCase` (a lowercase letter first, `fooBar`); `UPPER_CASE` (capitals, digits and underscores, `FOO_BAR`); `snake_case` (lowercase letters, digits and underscores, `foo_bar`); `Pascal_Snake_Case` (words joined by underscores, each beginning with a capital, `WP_REST_Request`, a PascalCase name passing too); `keep` -- The standards: perCs `camelCase`, psr12 `camelCase`, nette `camelCase` +- The standards: perCs `camelCase`, psr12 `camelCase`, nette `camelCase`, symfony `camelCase`, laravel `keep` - Rule `DressCode\Rules\Classes\NameCasingRule`, stage Structure +- Covers: `Generic.NamingConventions.CamelCapsFunctionName`, `PSR1.Methods.CamelCapsMethodName`, `PSR1.Methods.CamelCapsMethodName.NotCamelCaps`, `Squiz.NamingConventions.ValidFunctionName` ### naming.property @@ -1894,8 +2029,9 @@ The case of the name of a property, a promoted constructor parameter included, w - By default `keep` - Takes: `PascalCase` (a capital first and a lowercase letter somewhere, `FooBar`, a name of two letters such as `IO` passing); `camelCase` (a lowercase letter first, `fooBar`); `UPPER_CASE` (capitals, digits and underscores, `FOO_BAR`); `snake_case` (lowercase letters, digits and underscores, `foo_bar`); `Pascal_Snake_Case` (words joined by underscores, each beginning with a capital, `WP_REST_Request`, a PascalCase name passing too); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `camelCase` +- The standards: perCs `keep`, psr12 `keep`, nette `camelCase`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\NameCasingRule`, stage Structure +- Covers: `Squiz.NamingConventions.ValidVariableName`, `Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps` ### naming.traitAffix @@ -1903,8 +2039,9 @@ The word `Trait` at the end of the name of a trait, `FooTrait`, which is reporte - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ClassKindInNameRule`, stage Structure +- Covers: `SlevomatCodingStandard.Classes.SuperfluousTraitNaming` ### naming.variable @@ -1912,8 +2049,9 @@ The case of the name of a variable and a parameter, `$this`, the superglobals an - By default `keep` - Takes: `PascalCase` (a capital first and a lowercase letter somewhere, `FooBar`, a name of two letters such as `IO` passing); `camelCase` (a lowercase letter first, `fooBar`); `UPPER_CASE` (capitals, digits and underscores, `FOO_BAR`); `snake_case` (lowercase letters, digits and underscores, `foo_bar`); `Pascal_Snake_Case` (words joined by underscores, each beginning with a capital, `WP_REST_Request`, a PascalCase name passing too); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `camelCase` +- The standards: perCs `keep`, psr12 `keep`, nette `camelCase`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\NameCasingRule`, stage Structure +- Covers: `Squiz.NamingConventions.ValidVariableName`, `Squiz.NamingConventions.ValidVariableName.NotCamelCaps`, `Squiz.NamingConventions.ValidVariableName.StringNotCamelCaps` ## casing @@ -1923,8 +2061,9 @@ The case of a class, an interface, a trait or an enum as its declaration writes - By default `keep` - Takes: `declared` (`stdClass`, `DateTime`, never `datetime`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `declared` +- The standards: perCs `keep`, psr12 `keep`, nette `declared`, symfony `declared`, laravel `keep` - Rule `DressCode\Rules\Namespaces\DeclaredNameCasingRule`, stage Structure +- Covers: `class_reference_name_casing` ### casing.function @@ -1932,8 +2071,9 @@ The case of a function as its declaration writes it, of one PHP declares only wh - By default `keep` - Takes: `declared` (`strlen`, never `StrLen`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `declared` +- The standards: perCs `keep`, psr12 `keep`, nette `declared`, symfony `declared`, laravel `declared` - Rule `DressCode\Rules\Namespaces\DeclaredNameCasingRule`, stage Structure +- Covers: `native_function_casing`, `Squiz.PHP.LowercasePHPFunctions` ### casing.hexDigit @@ -1941,8 +2081,9 @@ The digits `A` to `F` of a hexadecimal number; the prefixes `0x`, `0b` and `0o` - By default `keep` - Takes: `uppercase` (`0xFF`); `lowercase` (`0xff`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `uppercase` - Rule `DressCode\Rules\Literals\NumberNotationRule`, stage Structure +- Covers: `integer_literal_case` ### casing.keyword @@ -1950,8 +2091,9 @@ The case of keywords, `self` and `parent` included. - By default `keep` - Takes: `lowercase` (`if`, `function`, `self`, `parent`); `keep` -- The standards: perCs `lowercase`, psr12 `lowercase`, nette `lowercase` +- The standards: perCs `lowercase`, psr12 `lowercase`, nette `lowercase`, symfony `lowercase`, laravel `lowercase` - Rule `DressCode\Rules\Literals\BuiltinCasingRule`, stage Structure +- Covers: `lowercase_keywords`, `lowercase_static_reference`, `Generic.PHP.LowerCaseKeyword`, `Generic.PHP.LowerCaseType`, `PSR2.ControlStructures.SwitchDeclaration`, `Squiz.ControlStructures.ForEachLoopDeclaration`, `Squiz.ControlStructures.LowercaseDeclaration`, `Squiz.Functions.LowercaseFunctionKeywords` ### casing.magicConstant @@ -1959,8 +2101,9 @@ The case of the magic constants such as `__DIR__`. - By default `keep` - Takes: `uppercase` (`__DIR__`, `__CLASS__`); `lowercase` (`__dir__`, `__class__`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `uppercase` +- The standards: perCs `keep`, psr12 `keep`, nette `uppercase`, symfony `uppercase`, laravel `uppercase` - Rule `DressCode\Rules\Literals\BuiltinCasingRule`, stage Structure +- Covers: `magic_constant_casing` ### casing.method @@ -1968,7 +2111,7 @@ The case of the name of a method called, as its declaration writes it, and of a - By default `keep` - Takes: `declared` (`$order->getTotal()`, never `$order->gettotal()`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `declared` +- The standards: perCs `keep`, psr12 `keep`, nette `declared`, symfony `declared`, laravel `keep` - Rule `DressCode\Rules\Functions\MethodCasingRule`, stage Structure ### casing.trueFalseNull @@ -1977,8 +2120,9 @@ The case of `true`, `false` and `null`, a leading backslash staying. - By default `keep` - Takes: `lowercase` (`true`, `false`, `null`); `uppercase` (`TRUE`, `FALSE`, `NULL`); `keep` -- The standards: perCs `lowercase`, psr12 `lowercase`, nette `lowercase` +- The standards: perCs `lowercase`, psr12 `lowercase`, nette `lowercase`, symfony `lowercase`, laravel `lowercase` - Rule `DressCode\Rules\Literals\BuiltinCasingRule`, stage Structure +- Covers: `constant_case`, `Generic.PHP.LowerCaseConstant` ### casing.type @@ -1986,8 +2130,9 @@ The case of the built-in types of a declaration; `array`, `callable`, `static`, - By default `keep` - Takes: `lowercase` (`int`, `string`, `void`); `keep` -- The standards: perCs `lowercase`, psr12 `lowercase`, nette `lowercase` +- The standards: perCs `lowercase`, psr12 `lowercase`, nette `lowercase`, symfony `lowercase`, laravel `lowercase` - Rule `DressCode\Rules\Namespaces\DeclaredNameCasingRule`, stage Structure +- Covers: `native_type_declaration_casing`, `Generic.PHP.LowerCaseType` ## qualification @@ -1997,8 +2142,9 @@ A class, interface, trait or enum of the namespace of the file, `Order` in `name - By default `keep` - Takes: `bare` (bare, `Order` relative to the namespace); `backslashed` (with the leading backslash, `\Acme\Shop\Order`); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `bare` +- The standards: perCs `keep`, psr12 `keep`, nette `bare`, symfony `keep`, laravel `bare` - Rule `DressCode\Rules\Namespaces\ForeignNameQualificationRule`, stage Structure +- Covers: `fully_qualified_strict_types`, `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` - Modifies comments ### qualification.class.global @@ -2007,8 +2153,9 @@ A class of the global namespace referenced in a namespace, `DateTime`, which a b - By default `keep` - Takes: `imported` (imported, `use DateTime;` and `DateTime`); `backslashed` (with the leading backslash, `\DateTime`); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `backslashed`, laravel `keep` - Rule `DressCode\Rules\Namespaces\GlobalNameQualificationRule`, stage Structure +- Covers: `global_namespace_import`, `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` ### qualification.class.otherNamespace @@ -2018,8 +2165,9 @@ A name relative to an import or to the namespace, `Shop\Order`, stays as it is. - By default `keep` - Takes: `imported` (imported, `use Acme\Shop\Order;` and `Order`); `backslashed` (with the leading backslash, `\Acme\Shop\Order`); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `imported` +- The standards: perCs `keep`, psr12 `keep`, nette `imported`, symfony `keep`, laravel `imported` - Rule `DressCode\Rules\Namespaces\ForeignNameQualificationRule`, stage Structure +- Covers: `fully_qualified_strict_types`, `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` - Modifies comments ### qualification.class.phpdoc @@ -2030,8 +2178,9 @@ A name relative to an import or to the namespace, `Shop\Order`, stays as it is, - By default `keep` - Takes: `imported` (imported, `use Acme\Shop\Order;` and `@param Order $order`, a class of the namespace of the file bare, a global one where the file imports it); `backslashed` (with the leading backslash, `@param \Acme\Shop\Order $order`, a name an import reaches); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `imported` +- The standards: perCs `keep`, psr12 `keep`, nette `imported`, symfony `keep`, laravel `imported` - Rule `DressCode\Rules\Namespaces\ForeignNameQualificationRule`, stage Structure +- Covers: `fully_qualified_strict_types` - Modifies comments ### qualification.constant.currentNamespace @@ -2040,8 +2189,9 @@ A constant of the namespace of the file, `STATUS_PAID` in `namespace Acme\Shop`. - By default `keep` - Takes: `bare` (bare, `STATUS_PAID` relative to the namespace); `backslashed` (with the leading backslash, `\Acme\Shop\STATUS_PAID`); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Namespaces\ForeignNameQualificationRule`, stage Structure +- Covers: `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` - Modifies comments ### qualification.constant.global @@ -2050,8 +2200,9 @@ A global constant in a namespace, `PHP_EOL`, but where the compiler computes wit - By default `keep` - Takes: `bare` (bare, `PHP_EOL` without an import or a backslash, reached by the fallback at run time); `imported` (imported, `use const PHP_EOL;` and `PHP_EOL`); `backslashed` (with the leading backslash, `\PHP_EOL`); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `[backslashed, bare]`, laravel `keep` - Rule `DressCode\Rules\Namespaces\GlobalNameQualificationRule`, stage Structure +- Covers: `global_namespace_import`, `native_constant_invocation`, `SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalConstants`, `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### qualification.constant.optimized @@ -2062,8 +2213,9 @@ A constant merely passed to a call is not one the compiler computes with. - By default `keep` - Takes: `imported` (imported, `use const PHP_VERSION_ID;`, so that the compiler computes with it); `backslashed` (with the leading backslash, `\PHP_VERSION_ID`, so that the compiler computes with it); `bare` (bare, `PHP_VERSION_ID`, forgoing the optimization); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `[backslashed, bare]`, laravel `keep` - Rule `DressCode\Rules\Namespaces\GlobalNameQualificationRule`, stage Structure +- Covers: `global_namespace_import`, `native_constant_invocation`, `SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalConstants`, `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### qualification.constant.otherNamespace @@ -2072,8 +2224,9 @@ A constant of a namespace other than that of the file. - By default `keep` - Takes: `imported` (imported, `use const Acme\Shop\STATUS_PAID;` and `STATUS_PAID`); `backslashed` (with the leading backslash, `\Acme\Shop\STATUS_PAID`); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Namespaces\ForeignNameQualificationRule`, stage Structure +- Covers: `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` - Modifies comments ### qualification.currentClass @@ -2082,8 +2235,9 @@ How a class refers to itself in an expression: a static access, an instantiation - By default `keep` - Takes: `self` (the class named `self` inside itself); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `self` +- The standards: perCs `keep`, psr12 `keep`, nette `self`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\SelfForCurrentClassRule`, stage Structure +- Covers: `self_accessor`, `SlevomatCodingStandard.Classes.UselessLateStaticBinding`, `Squiz.Classes.SelfMemberReference` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### qualification.function.currentNamespace @@ -2092,8 +2246,9 @@ A function of the namespace of the file, `normalize()` in `namespace Acme\Text`. - By default `keep` - Takes: `bare` (bare, `normalize()` relative to the namespace); `backslashed` (with the leading backslash, `\Acme\Text\normalize()`); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Namespaces\ForeignNameQualificationRule`, stage Structure +- Covers: `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` - Modifies comments ### qualification.function.global @@ -2104,8 +2259,9 @@ Not a function of the namespace of the file, nor a name in a file without a name - By default `keep` - Takes: `bare` (bare, `ucfirst()` without an import or a backslash, reached by the fallback at run time); `imported` (imported, `use function ucfirst;` and `ucfirst()`); `backslashed` (with the leading backslash, `\ucfirst()`); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `[backslashed, bare]`, laravel `keep` - Rule `DressCode\Rules\Namespaces\GlobalNameQualificationRule`, stage Structure +- Covers: `global_namespace_import`, `native_function_invocation`, `SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalFunctions`, `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### qualification.function.optimized @@ -2116,9 +2272,10 @@ A function is optimized in a namespace where one of its calls there is, an unpac - By default `keep` - Takes: `imported` (imported, `use function strlen;`, so that the compiler optimizes it); `backslashed` (with the leading backslash, `\strlen()`, so that the compiler optimizes it); `bare` (bare, `strlen()`, forgoing the optimization); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `[backslashed, bare]`, laravel `keep` - Rule `DressCode\Rules\Namespaces\GlobalNameQualificationRule`, stage Structure - Rule `DressCode\Rules\Namespaces\OptimizedCallNotationRule`, stage Structure +- Covers: `global_namespace_import`, `native_function_invocation`, `SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalFunctions`, `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly`, `SlevomatCodingStandard.PHP.OptimizedFunctionsWithoutUnpacking` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### qualification.function.otherNamespace @@ -2127,8 +2284,9 @@ A function of a namespace other than that of the file. - By default `keep` - Takes: `imported` (imported, `use function Acme\Text\normalize;` and `normalize()`); `backslashed` (with the leading backslash, `\Acme\Text\normalize()`); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Namespaces\ForeignNameQualificationRule`, stage Structure +- Covers: `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` - Modifies comments ### qualification.leadingBackslash @@ -2137,8 +2295,9 @@ The leading backslash of a name where it changes nothing, in a file without a na - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Namespaces\UselessLeadingBackslashRule`, stage Structure +- Covers: `PhpCsFixerCustomFixers/no_leading_slash_in_global_namespace` ### qualification.staticInFinalClass @@ -2148,8 +2307,9 @@ The return type `static` stays, saying what a subclass would return. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `required` - Rule `DressCode\Rules\Classes\SelfForCurrentClassRule`, stage Structure +- Covers: `self_static_accessor`, `SlevomatCodingStandard.Classes.UselessLateStaticBinding` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ## imports @@ -2160,8 +2320,9 @@ The return type `static` stays, saying what a subclass would return. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Namespaces\UselessAliasRule`, stage Structure +- Covers: `no_unneeded_import_alias`, `SlevomatCodingStandard.Namespaces.UselessAlias` ### imports.fromCurrentNamespace @@ -2169,8 +2330,9 @@ An import of a class of the namespace it stands in, `use Acme\Shop\Order;` insid - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Namespaces\UselessCurrentNamespaceImportRule`, stage Structure +- Covers: `SlevomatCodingStandard.Namespaces.UseFromSameNamespace` ### imports.grouping.class @@ -2178,8 +2340,9 @@ How the imports of classes are put into `use` statements, a name of the global n - By default `keep` - Takes: `separated` (a `use` of its own for every name, `use Foo; use Bar;`); `combined` (all names of the kind in one `use`, `use Foo, Bar;`, one per namespace declaration); `grouped` (the names of one namespace in one group use, `use Acme\Shop\{Order, Cart};`, from `imports.groupMinNames` of them on); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `[separated, grouped]` +- The standards: perCs `keep`, psr12 `keep`, nette `[separated, grouped]`, symfony `separated`, laravel `separated` - Rule `DressCode\Rules\Namespaces\ImportNotationRule`, stage Structure +- Covers: `group_import`, `single_import_per_statement`, `PSR2.Namespaces.UseDeclaration`, `SlevomatCodingStandard.Namespaces.DisallowGroupUse`, `SlevomatCodingStandard.Namespaces.MultipleUsesPerLine` ### imports.grouping.constant @@ -2187,8 +2350,9 @@ How the imports of constants are put into `use` statements, a name of the global - By default `keep` - Takes: `separated` (a `use` of its own for every name, `use Foo; use Bar;`); `combined` (all names of the kind in one `use`, `use Foo, Bar;`, one per namespace declaration); `grouped` (the names of one namespace in one group use, `use Acme\Shop\{Order, Cart};`, from `imports.groupMinNames` of them on); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `[combined, grouped]` +- The standards: perCs `keep`, psr12 `keep`, nette `[combined, grouped]`, symfony `separated`, laravel `separated` - Rule `DressCode\Rules\Namespaces\ImportNotationRule`, stage Structure +- Covers: `group_import`, `single_import_per_statement`, `PSR2.Namespaces.UseDeclaration`, `SlevomatCodingStandard.Namespaces.DisallowGroupUse`, `SlevomatCodingStandard.Namespaces.MultipleUsesPerLine` ### imports.grouping.function @@ -2196,8 +2360,9 @@ How the imports of functions are put into `use` statements, a name of the global - By default `keep` - Takes: `separated` (a `use` of its own for every name, `use Foo; use Bar;`); `combined` (all names of the kind in one `use`, `use Foo, Bar;`, one per namespace declaration); `grouped` (the names of one namespace in one group use, `use Acme\Shop\{Order, Cart};`, from `imports.groupMinNames` of them on); a list of them, every one passing and the first written where none matches; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `[combined, grouped]` +- The standards: perCs `keep`, psr12 `keep`, nette `[combined, grouped]`, symfony `separated`, laravel `separated` - Rule `DressCode\Rules\Namespaces\ImportNotationRule`, stage Structure +- Covers: `group_import`, `single_import_per_statement`, `PSR2.Namespaces.UseDeclaration`, `SlevomatCodingStandard.Namespaces.DisallowGroupUse`, `SlevomatCodingStandard.Namespaces.MultipleUsesPerLine` ### imports.groupMinNames @@ -2205,7 +2370,7 @@ How many names of one namespace make a group use where `grouped` is written, few - By default `2` - Takes: a count from 2 -- The standards: perCs `2`, psr12 `2`, nette `2` +- The standards: perCs `2`, psr12 `2`, nette `2`, symfony `2`, laravel `2` - Rule `DressCode\Rules\Namespaces\ImportNotationRule`, stage Structure ### imports.leadingBackslash @@ -2214,8 +2379,9 @@ The leading backslash of an import, which changes nothing, `use \Foo\Bar;`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Namespaces\UselessLeadingBackslashRule`, stage Structure +- Covers: `PhpCsFixerCustomFixers/no_leading_slash_in_global_namespace`, `no_leading_import_slash`, `PSR12.Files.ImportStatement`, `SlevomatCodingStandard.Namespaces.UseDoesNotStartWithBackslash` ### imports.order.caseSensitive @@ -2223,8 +2389,9 @@ Whether `Acme` sorts before `acme` rather than with it. - By default `no` - Takes: `yes`; `no` -- The standards: perCs `no`, psr12 `no`, nette `no` +- The standards: perCs `no`, psr12 `no`, nette `no`, symfony `no`, laravel `no` - Rule `DressCode\Rules\Namespaces\ImportOrderRule`, stage Structure +- Covers: `ordered_imports`, `SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses` ### imports.order.kinds @@ -2232,8 +2399,9 @@ The order of the kinds among consecutive imports, a kind not listed following in - By default `keep` - Takes: an order of `class` (the imports of classes); `function` (the imports of functions); `constant` (the imports of constants); `keep` -- The standards: perCs `[class, function, constant]`, psr12 `[class, function, constant]`, nette `[class, function, constant]` +- The standards: perCs `[class, function, constant]`, psr12 `[class, function, constant]`, nette `[class, function, constant]`, symfony `[class, function, constant]`, laravel `[constant, class, function]` - Rule `DressCode\Rules\Namespaces\ImportOrderRule`, stage Structure +- Covers: `ordered_imports`, `SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses` ### imports.order.names @@ -2241,8 +2409,9 @@ The order of the names of one kind among consecutive imports, the names of a sta - By default `keep` - Takes: `alphabetical` (the names of a kind sorted alphabetically); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `alphabetical` +- The standards: perCs `keep`, psr12 `keep`, nette `alphabetical`, symfony `alphabetical`, laravel `alphabetical` - Rule `DressCode\Rules\Namespaces\ImportOrderRule`, stage Structure +- Covers: `ordered_imports`, `SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses` ### imports.unused @@ -2250,8 +2419,9 @@ An import nothing uses is removed, a class name in a doc comment using the impor - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Namespaces\NoUnusedImportsRule`, stage Structure +- Covers: `no_unused_imports`, `SlevomatCodingStandard.Namespaces.UnusedUses` ## classes @@ -2261,8 +2431,9 @@ The empty parentheses of an anonymous class. - By default `keep` - Takes: `required` (always there, `new class()`); `forbidden` (never there, `new class`); `keep` -- The standards: perCs `forbidden`, psr12 `keep`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Expressions\EmptyArgumentParenthesesRule`, stage Structure +- Covers: `new_with_braces`, `new_with_parentheses` ### classes.emptyParentheses.attribute @@ -2270,8 +2441,9 @@ The empty parentheses of an attribute. - By default `keep` - Takes: `required` (always there, `#[Foo()]`); `forbidden` (never there, `#[Foo]`); `keep` -- The standards: perCs `forbidden`, psr12 `keep`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\EmptyArgumentParenthesesRule`, stage Structure +- Covers: `attribute_empty_parentheses` ### classes.emptyParentheses.instantiation @@ -2279,8 +2451,9 @@ The empty parentheses of `new` with a class, `new static` and `new $class` alike - By default `keep` - Takes: `required` (always there, `new Foo()`); `forbidden` (never there, `new Foo`); `keep` -- The standards: perCs `required`, psr12 `required`, nette `forbidden` +- The standards: perCs `required`, psr12 `required`, nette `forbidden`, symfony `required`, laravel `forbidden` - Rule `DressCode\Rules\Expressions\EmptyArgumentParenthesesRule`, stage Structure +- Covers: `new_with_braces`, `new_with_parentheses`, `PSR12.Classes.ClassInstantiation`, `SlevomatCodingStandard.ControlStructures.NewWithParentheses`, `SlevomatCodingStandard.ControlStructures.NewWithoutParentheses` ### classes.instantiationWrapping @@ -2288,8 +2461,9 @@ The parentheses around an instantiation a member is accessed on, `new Foo()->bar - By default `keep` - Takes: `forbidden` (never there); `required` (always there); `keep` -- The standards: perCs `forbidden`, psr12 `keep`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\InstantiationWrappingRule`, stage Structure +- Covers: `new_expression_parentheses` - Needs PHP >=8.4 ### classes.internalClass.except @@ -2298,7 +2472,7 @@ The annotations and the attributes that keep an internal class as it is, an enti - By default `[@final, @Entity, @ORM\Entity, @ORM\Mapping\Entity, @Mapping\Entity, @Document, @ODM\Document, Doctrine\ORM\Mapping\Entity, Doctrine\ODM\MongoDB\Mapping\Annotations\Document]` - Takes: a list of names -- The standards: perCs `[@final, @Entity, @ORM\Entity, @ORM\Mapping\Entity, @Mapping\Entity, @Document, @ODM\Document, Doctrine\ORM\Mapping\Entity, Doctrine\ODM\MongoDB\Mapping\Annotations\Document]`, psr12 `[@final, @Entity, @ORM\Entity, @ORM\Mapping\Entity, @Mapping\Entity, @Document, @ODM\Document, Doctrine\ORM\Mapping\Entity, Doctrine\ODM\MongoDB\Mapping\Annotations\Document]`, nette `[@final, @Entity, @ORM\Entity, @ORM\Mapping\Entity, @Mapping\Entity, @Document, @ODM\Document, Doctrine\ORM\Mapping\Entity, Doctrine\ODM\MongoDB\Mapping\Annotations\Document]` +- The standards: perCs `[@final, @Entity, @ORM\Entity, @ORM\Mapping\Entity, @Mapping\Entity, @Document, @ODM\Document, Doctrine\ORM\Mapping\Entity, Doctrine\ODM\MongoDB\Mapping\Annotations\Document]`, psr12 `[@final, @Entity, @ORM\Entity, @ORM\Mapping\Entity, @Mapping\Entity, @Document, @ODM\Document, Doctrine\ORM\Mapping\Entity, Doctrine\ODM\MongoDB\Mapping\Annotations\Document]`, nette `[@final, @Entity, @ORM\Entity, @ORM\Mapping\Entity, @Mapping\Entity, @Document, @ODM\Document, Doctrine\ORM\Mapping\Entity, Doctrine\ODM\MongoDB\Mapping\Annotations\Document]`, symfony `[@final, @Entity, @ORM\Entity, @ORM\Mapping\Entity, @Mapping\Entity, @Document, @ODM\Document, Doctrine\ORM\Mapping\Entity, Doctrine\ODM\MongoDB\Mapping\Annotations\Document]`, laravel `[@final, @Entity, @ORM\Entity, @ORM\Mapping\Entity, @Mapping\Entity, @Document, @ODM\Document, Doctrine\ORM\Mapping\Entity, Doctrine\ODM\MongoDB\Mapping\Annotations\Document]` - Rule `DressCode\Rules\Classes\FinalForInternalClassRule`, stage Structure ### classes.internalClass.final @@ -2307,8 +2481,9 @@ The `final` of a class its author marked as internal, which nothing outside the - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\FinalForInternalClassRule`, stage Structure +- Covers: `final_internal_class` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### classes.internalClass.markers @@ -2317,7 +2492,7 @@ The annotations, with their `@`, and the attributes, by their fully qualified cl - By default `[@internal]` - Takes: a list of names -- The standards: perCs `[@internal]`, psr12 `[@internal]`, nette `[@internal]` +- The standards: perCs `[@internal]`, psr12 `[@internal]`, nette `[@internal]`, symfony `[@internal]`, laravel `[@internal]` - Rule `DressCode\Rules\Classes\FinalForInternalClassRule`, stage Structure ### classes.members.grouping.constant @@ -2326,8 +2501,9 @@ A declaration of several constants of a class, `const A = 1, B = 2;`. - By default `keep` - Takes: `separated` (one member per declaration, each sharing the modifiers and the type of the original); `keep` -- The standards: perCs `separated`, psr12 `keep`, nette `keep` +- The standards: perCs `separated`, psr12 `keep`, nette `keep`, symfony `separated`, laravel `separated` - Rule `DressCode\Rules\Classes\NoGroupedDeclarationsRule`, stage Structure +- Covers: `single_class_element_per_statement`, `SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition` ### classes.members.grouping.property @@ -2335,8 +2511,9 @@ A declaration of several properties, `public $a, $b;`. - By default `keep` - Takes: `separated` (one member per declaration, each sharing the modifiers and the type of the original); `keep` -- The standards: perCs `separated`, psr12 `separated`, nette `separated` +- The standards: perCs `separated`, psr12 `separated`, nette `separated`, symfony `separated`, laravel `separated` - Rule `DressCode\Rules\Classes\NoGroupedDeclarationsRule`, stage Structure +- Covers: `single_class_element_per_statement`, `SlevomatCodingStandard.Classes.DisallowMultiPropertyDefinition` ### classes.members.grouping.traitUse @@ -2344,8 +2521,9 @@ A `use` of several traits, `use A, B;`. - By default `keep` - Takes: `separated` (one member per declaration, each sharing the modifiers and the type of the original); `keep` -- The standards: perCs `separated`, psr12 `separated`, nette `separated` +- The standards: perCs `separated`, psr12 `separated`, nette `separated`, symfony `separated`, laravel `keep` - Rule `DressCode\Rules\Classes\NoGroupedDeclarationsRule`, stage Structure +- Covers: `single_trait_insert_per_statement`, `PSR12.Traits.UseDeclaration`, `SlevomatCodingStandard.Classes.TraitUseDeclaration` ### classes.members.order @@ -2353,8 +2531,9 @@ The order of the members of a class by kind, a member taking the most particular - By default `keep` - Takes: an order of `traitUse` (a trait use); `enumCase` (a case of an enum); `constant` (a constant of any visibility); `publicConstant` (a public constant); `protectedConstant` (a protected constant); `privateConstant` (a private constant); `property` (a property of any visibility); `publicProperty` (a public property); `protectedProperty` (a protected property); `privateProperty` (a private property); `publicStaticProperty` (a public static property); `protectedStaticProperty` (a protected static property); `privateStaticProperty` (a private static property); `method` (a method of any visibility); `publicMethod` (a public method); `protectedMethod` (a protected method); `privateMethod` (a private method); `publicStaticMethod` (a public static method); `protectedStaticMethod` (a protected static method); `privateStaticMethod` (a private static method); `constructor` (the constructor); `destructor` (the destructor); `magicMethod` (a method whose name begins with `__`); `keep` -- The standards: perCs `[traitUse]`, psr12 `[traitUse]`, nette `[traitUse, enumCase, publicConstant, protectedConstant, privateConstant, publicProperty, protectedProperty, privateProperty]` +- The standards: perCs `[traitUse]`, psr12 `[traitUse]`, nette `[traitUse, enumCase, publicConstant, protectedConstant, privateConstant, publicProperty, protectedProperty, privateProperty]`, symfony `[traitUse]`, laravel `keep` - Rule `DressCode\Rules\Classes\MemberOrderRule`, stage Structure +- Covers: `ordered_class_elements`, `PSR12.Traits.UseDeclaration` ### classes.modifiers.implied @@ -2362,8 +2541,9 @@ A modifier PHP implies anyway, `final` in a final class, `readonly` in a readonl - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\UselessModifierRule`, stage Structure +- Covers: `no_redundant_readonly_property`, `no_unneeded_final_method`, `Generic.CodeAnalysis.UnnecessaryFinalModifier` ### classes.modifiers.order @@ -2371,8 +2551,9 @@ The order of the modifiers of a property, a promoted one included, a method, a c - By default `keep` - Takes: `canonical` (`abstract` or `final`, visibility, set visibility, `static`, `readonly`); `keep` -- The standards: perCs `canonical`, psr12 `canonical`, nette `canonical` +- The standards: perCs `canonical`, psr12 `canonical`, nette `canonical`, symfony `canonical`, laravel `canonical` - Rule `DressCode\Rules\Classes\ModifierOrderRule`, stage Structure +- Covers: `modifier_keywords`, `visibility_required`, `PSR2.Classes.PropertyDeclaration`, `PSR2.Methods.MethodDeclaration`, `SlevomatCodingStandard.Classes.PropertyDeclaration` ### classes.overridingMethod.parameterName @@ -2380,7 +2561,7 @@ The names of the parameters of a method overriding one of a parent class or an i - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\OverridingSignatureRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` - Modifies comments @@ -2392,7 +2573,7 @@ A method that only forwards to the parent with the same arguments adds nothing. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\UselessOverridingMethodRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -2403,7 +2584,7 @@ The attribute where the method declares the return type of what it implements. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\UselessReturnTypeWillChangeRule`, stage Structure ### classes.overridingMethod.signature @@ -2412,7 +2593,7 @@ The return type, the types of the parameters, the parameters added with a defaul - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\OverridingSignatureRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` - Modifies comments @@ -2424,8 +2605,9 @@ A `protected` member of an enum, which is `private`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `forbidden`, psr12 `keep`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `keep` - Rule `DressCode\Rules\Classes\PrivateForProtectedRule`, stage Structure +- Covers: `protected_to_private` ### classes.protectedInFinalClass @@ -2433,8 +2615,9 @@ A `protected` member of a final class with no parent and no trait, which is `pri - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `forbidden`, laravel `keep` - Rule `DressCode\Rules\Classes\PrivateForProtectedRule`, stage Structure +- Covers: `protected_to_private` ### classes.staticMethod @@ -2442,8 +2625,9 @@ The `static` keyword of a method that does not use `$this`, of the visibilities - By default `keep` - Takes: `required` (always there, `static function`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\StaticForMethodWithoutThisRule`, stage Structure +- Covers: `static_private_method` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### classes.staticMethodVisibilities @@ -2452,7 +2636,7 @@ The visibilities of the methods made static. - By default `[private]` - Takes: a list of `private` (a private method); `protected` (a protected method, where no child and no ancestor can declare it); `public` (a public method, where no child and no ancestor can declare it) -- The standards: perCs `[private]`, psr12 `[private]`, nette `[private]` +- The standards: perCs `[private]`, psr12 `[private]`, nette `[private]`, symfony `[private]`, laravel `[private]` - Rule `DressCode\Rules\Classes\StaticForMethodWithoutThisRule`, stage Structure ### classes.untypedPropertyNullDefault @@ -2461,8 +2645,9 @@ The visibilities of the methods made static. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `keep` - Rule `DressCode\Rules\Classes\UselessNullInitializationRule`, stage Structure +- Covers: `no_null_property_initialization` ### classes.visibility.constant @@ -2470,8 +2655,9 @@ The visibility of a constant of a class, an interface, a trait or an enum. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `required`, psr12 `required`, nette `required` +- The standards: perCs `required`, psr12 `required`, nette `required`, symfony `required`, laravel `keep` - Rule `DressCode\Rules\Classes\VisibilityRequiredRule`, stage Structure +- Covers: `modifier_keywords`, `visibility_required`, `PSR12.Properties.ConstantVisibility`, `PSR2.Classes.PropertyDeclaration`, `SlevomatCodingStandard.Classes.ClassConstantVisibility`, `Squiz.Scope.MethodScope` ### classes.visibility.interfaceMethod @@ -2479,8 +2665,9 @@ The `public` of a method of an interface, which is public whatever it says. - By default `keep` - Takes: `required` (always there); `forbidden` (never there); `keep` -- The standards: perCs `required`, psr12 `required`, nette `forbidden` +- The standards: perCs `required`, psr12 `required`, nette `forbidden`, symfony `required`, laravel `required` - Rule `DressCode\Rules\Classes\VisibilityRequiredRule`, stage Structure +- Covers: `modifier_keywords`, `visibility_required`, `Squiz.Scope.MethodScope` ### classes.visibility.method @@ -2488,8 +2675,9 @@ The visibility of a method outside an interface, whose methods take `interfaceMe - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `required`, psr12 `required`, nette `required` +- The standards: perCs `required`, psr12 `required`, nette `required`, symfony `required`, laravel `required` - Rule `DressCode\Rules\Classes\VisibilityRequiredRule`, stage Structure +- Covers: `modifier_keywords`, `visibility_required`, `PSR12.Properties.ConstantVisibility`, `PSR2.Classes.PropertyDeclaration`, `SlevomatCodingStandard.Classes.ClassConstantVisibility`, `Squiz.Scope.MethodScope` ### classes.visibility.property @@ -2497,8 +2685,9 @@ The visibility of a property, a promoted one included, `var` becoming `public` a - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `required`, psr12 `required`, nette `required` +- The standards: perCs `required`, psr12 `required`, nette `required`, symfony `required`, laravel `required` - Rule `DressCode\Rules\Classes\VisibilityRequiredRule`, stage Structure +- Covers: `modifier_keywords`, `visibility_required`, `PSR12.Properties.ConstantVisibility`, `PSR2.Classes.PropertyDeclaration`, `SlevomatCodingStandard.Classes.ClassConstantVisibility`, `Squiz.Scope.MethodScope` ### classes.visibility.publicWithSet @@ -2506,7 +2695,7 @@ The `public` of a property with a set visibility, `public protected(set) int $x` - By default `keep` - Takes: `required` (always there); `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `required`, nette `required` +- The standards: perCs `keep`, psr12 `required`, nette `required`, symfony `required`, laravel `keep` - Rule `DressCode\Rules\Classes\PublicWithSetVisibilityRule`, stage Structure - Needs PHP >=8.4 @@ -2518,7 +2707,7 @@ The `public` of a property with a set visibility, `public protected(set) int $x` - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `required`, psr12 `keep`, nette `keep` +- The standards: perCs `required`, psr12 `keep`, nette `keep`, symfony `required`, laravel `keep` - Rule `DressCode\Rules\Functions\ArrowFunctionForClosureRule`, stage Structure - Needs PHP >=7.4 @@ -2530,8 +2719,9 @@ Not about the `static` of the arrow function, which `functions.staticClosure` de - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\ArrowFunctionForClosureRule`, stage Structure +- Covers: `use_arrow_functions`, `SlevomatCodingStandard.Functions.RequireArrowFunction` - Needs PHP >=7.4 ### functions.arrowFunctionNested @@ -2540,8 +2730,9 @@ Whether a closure holding another closure or arrow function becomes one too. - By default `no` - Takes: `yes`; `no` -- The standards: perCs `no`, psr12 `no`, nette `yes` +- The standards: perCs `no`, psr12 `no`, nette `yes`, symfony `no`, laravel `no` - Rule `DressCode\Rules\Functions\ArrowFunctionForClosureRule`, stage Structure +- Covers: `SlevomatCodingStandard.Functions.RequireArrowFunction` - Needs PHP >=7.4 ### functions.innerFunction @@ -2550,8 +2741,9 @@ A named function declared inside a function, a method, a closure or a property h - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoInnerFunctionsRule`, stage Structure +- Covers: `Squiz.PHP.InnerFunctions` ### functions.namedBooleanArgument @@ -2559,7 +2751,7 @@ A `true` or `false` passed by position to a parameter declared `bool`, written w - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NamedArgumentForBooleanRule`, stage Structure - Needs PHP >=8.0 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -2570,7 +2762,7 @@ The functions and methods whose arguments stay positional, a method written with - By default `[]` - Takes: a list of names -- The standards: perCs `[]`, psr12 `[]`, nette `[]` +- The standards: perCs `[]`, psr12 `[]`, nette `[]`, symfony `[]`, laravel `[]` - Rule `DressCode\Rules\Functions\NamedArgumentForBooleanRule`, stage Structure - Needs PHP >=8.0 @@ -2580,8 +2772,9 @@ The `static` keyword of a closure or an arrow function that does not use `$this` - By default `keep` - Takes: `required` (always there, `static function`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\StaticForClosureWithoutThisRule`, stage Structure +- Covers: `static_lambda`, `SlevomatCodingStandard.Functions.StaticClosure` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### functions.trailingBareReturn @@ -2590,8 +2783,9 @@ The `static` keyword of a closure or an arrow function that does not use `$this` - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\ControlFlow\UselessReturnRule`, stage Structure +- Covers: `no_useless_return` ### functions.unreachableParameterDefault @@ -2599,8 +2793,9 @@ A default a required parameter after it makes unreachable. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `forbidden` - Rule `DressCode\Rules\Functions\UselessParameterDefaultRule`, stage Structure +- Covers: `no_unreachable_default_argument_value`, `PEAR.Functions.ValidDefaultValue`, `SlevomatCodingStandard.Functions.UselessParameterDefaultValue` ## types @@ -2612,8 +2807,9 @@ Not an arrow function, whose value is its expression. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Types\NativeTypeRequiredRule`, stage Structure +- Covers: `void_return`, `SlevomatCodingStandard.TypeHints.ReturnTypeHint` - Modifies comments ### types.declaration.constant @@ -2622,7 +2818,7 @@ A class constant declares the type of its value. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Types\ConstantTypeRequiredRule`, stage Structure - Needs PHP >=8.3 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -2635,8 +2831,9 @@ Not a parameter of a closure, whose types belong to the call it is passed to. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Types\NativeTypeRequiredRule`, stage Structure +- Covers: `phpdoc_to_param_type`, `SlevomatCodingStandard.TypeHints.ParameterTypeHint` - Modifies comments - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -2646,8 +2843,9 @@ A property declares its type, a promoted one included, taken from `@var`, or the - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Types\NativeTypeRequiredRule`, stage Structure +- Covers: `phpdoc_to_property_type`, `SlevomatCodingStandard.TypeHints.PropertyTypeHint` - Modifies comments - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -2657,8 +2855,9 @@ A function or a method declares its return type, taken from `@return`, `void` wh - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Types\NativeTypeRequiredRule`, stage Structure +- Covers: `phpdoc_to_return_type`, `void_return`, `SlevomatCodingStandard.TypeHints.ReturnTypeHint` - Modifies comments - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -2668,8 +2867,9 @@ How a native type of one type and `null` is written. - By default `keep` - Takes: `'?int'` (a single type with `null` written with `?`); `'int|null'` (a union with `null`, which `types.nullPosition` places); `keep` -- The standards: perCs `'?int'`, psr12 `keep`, nette `'?int'` +- The standards: perCs `'?int'`, psr12 `keep`, nette `'?int'`, symfony `'?int'`, laravel `'?int'` - Rule `DressCode\Rules\Types\TypeNotationRule`, stage Structure +- Covers: `nullable_type_declaration` ### types.nullPosition @@ -2677,8 +2877,9 @@ Where `null` stands in a native union type. - By default `keep` - Takes: `last` (`int|string|null`); `first` (`null|int|string`); `keep` -- The standards: perCs `last`, psr12 `keep`, nette `last` +- The standards: perCs `last`, psr12 `keep`, nette `last`, symfony `last`, laravel `keep` - Rule `DressCode\Rules\Types\TypeNotationRule`, stage Structure +- Covers: `ordered_types` ### types.traversableClasses @@ -2686,7 +2887,7 @@ Classes treated like `array` and `iterable`, whose annotation says what their it - By default `[Traversable]` - Takes: a list of names -- The standards: perCs `[Traversable]`, psr12 `[Traversable]`, nette `[Traversable]` +- The standards: perCs `[Traversable]`, psr12 `[Traversable]`, nette `[Traversable]`, symfony `[Traversable]`, laravel `[Traversable]` - Rule `DressCode\Rules\PhpDoc\PhpdocTypeNotationRule`, stage Finishing - Rule `DressCode\Rules\Types\NativeTypeRequiredRule`, stage Structure - Modifies comments @@ -2697,8 +2898,9 @@ The order of the types of a native union. - By default `keep` - Takes: `alphabetical` (the types beside `null` sorted by name, case-insensitively, an intersection by its first name); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Types\TypeNotationRule`, stage Structure +- Covers: `ordered_types` ## controlFlow @@ -2710,8 +2912,9 @@ Not about an `if` and an `else` each returning a value, which `controlFlow.ifEls - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `forbidden`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\UselessElseRule`, stage Structure +- Covers: `no_useless_else`, `SlevomatCodingStandard.ControlStructures.EarlyExit` ### controlFlow.elseif @@ -2719,8 +2922,9 @@ The keyword of a branch that follows `if`. - By default `keep` - Takes: `'elseif'` (`elseif` in one word, never `else if`); `keep` -- The standards: perCs `'elseif'`, psr12 `'elseif'`, nette `'elseif'` +- The standards: perCs `'elseif'`, psr12 `'elseif'`, nette `'elseif'`, symfony `'elseif'`, laravel `'elseif'` - Rule `DressCode\Rules\ControlFlow\ElseifNotationRule`, stage Structure +- Covers: `elseif`, `PSR2.ControlStructures.ElseIfDeclaration` ### controlFlow.elseifAfterReturn @@ -2728,8 +2932,9 @@ An `elseif` after an `if` that always leaves, which becomes an `if` of its own w - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\UselessElseRule`, stage Structure +- Covers: `no_superfluous_elseif`, `SlevomatCodingStandard.ControlStructures.EarlyExit` ### controlFlow.emptyStatement @@ -2737,8 +2942,9 @@ The second `;` of `foo();;`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\ControlFlow\NoEmptyStatementsRule`, stage Structure +- Covers: `no_empty_statement`, `SlevomatCodingStandard.PHP.UselessSemicolon` ### controlFlow.ifElseReturning @@ -2748,8 +2954,9 @@ Not about the ternary of `true` and `false` it may write, which `expressions.ter - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\TernaryForIfRule`, stage Structure +- Covers: `SlevomatCodingStandard.ControlStructures.RequireTernaryOperator` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### controlFlow.ifReturningTrueFalse @@ -2760,8 +2967,9 @@ Not about branches returning other values, which `controlFlow.ifElseReturning` w - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\ReturnForBooleanIfRule`, stage Structure +- Covers: `simplified_if_return`, `SlevomatCodingStandard.ControlStructures.UselessIfConditionWithReturn` ### controlFlow.switch.fallThroughComment @@ -2769,8 +2977,9 @@ The text of the comment a non-empty `case` falling through to the next one carri - By default `keep` - Takes: a text, or a list of them, every one passing and the first written; `keep` -- The standards: perCs `no break`, psr12 `no break`, nette `break omitted` +- The standards: perCs `no break`, psr12 `no break`, nette `break omitted`, symfony `no break`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\FallThroughCommentRule`, stage Structure +- Covers: `no_break_comment`, `PSR2.ControlStructures.SwitchDeclaration` - Modifies comments ### controlFlow.trailingIf @@ -2779,8 +2988,9 @@ An `if` ending a function or a loop body becomes a guard that leaves early, and - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\EarlyExitForTrailingIfRule`, stage Structure +- Covers: `SlevomatCodingStandard.ControlStructures.EarlyExit` ### controlFlow.trailingIfMinStatements @@ -2788,8 +2998,9 @@ The statements the body of a trailing `if` has at least for it to become a guard - By default `2` - Takes: a count from 1 -- The standards: perCs `2`, psr12 `2`, nette `2` +- The standards: perCs `2`, psr12 `2`, nette `2`, symfony `2`, laravel `2` - Rule `DressCode\Rules\ControlFlow\EarlyExitForTrailingIfRule`, stage Structure +- Covers: `SlevomatCodingStandard.ControlStructures.EarlyExit` ## expressions @@ -2799,8 +3010,9 @@ The statements the body of a trailing `if` has at least for it to become a guard - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `keep` - Rule `DressCode\Rules\Expressions\IncrementRule`, stage Structure +- Covers: `standardize_increment` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### expressions.assignment.increment @@ -2809,8 +3021,9 @@ An increment or a decrement whose value is not read, a statement or a step of `f - By default `keep` - Takes: `prefix` (`++$i`, `--$i`); `postfix` (`$i++`, `$i--`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `postfix` +- The standards: perCs `keep`, psr12 `keep`, nette `postfix`, symfony `prefix`, laravel `postfix` - Rule `DressCode\Rules\Expressions\IncrementRule`, stage Structure +- Covers: `increment_style` ### expressions.assignment.targetInValue @@ -2820,8 +3033,9 @@ Not about the `$a += 1` it may write, which `expressions.assignment.addingOne` w - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\CombinedAssignmentForRepeatedTargetRule`, stage Structure +- Covers: `assign_null_coalescing_to_coalesce_equal`, `long_to_shorthand_operator`, `SlevomatCodingStandard.ControlStructures.RequireNullCoalesceEqualOperator`, `SlevomatCodingStandard.Operators.RequireCombinedAssignmentOperator` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### expressions.cast @@ -2830,8 +3044,9 @@ The type name of a cast. - By default `keep` - Takes: `canonical` (`(int)`, `(bool)`, `(float)`, `(string)` in lowercase, never `(integer)`, `(Boolean)`, `(double)`, `(binary)`); `keep` -- The standards: perCs `canonical`, psr12 `canonical`, nette `canonical` +- The standards: perCs `canonical`, psr12 `canonical`, nette `canonical`, symfony `canonical`, laravel `canonical` - Rule `DressCode\Rules\Expressions\CastCanonicalTypeRule`, stage Structure +- Covers: `lowercase_cast`, `short_scalar_cast`, `Generic.PHP.LowerCaseType`, `PSR12.Keywords.ShortFormTypeKeywords`, `SlevomatCodingStandard.PHP.TypeCast`, `Squiz.WhiteSpace.CastSpacing` ### expressions.coalesce @@ -2839,8 +3054,9 @@ The type name of a cast. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\NullCoalescingForNullTernaryRule`, stage Structure +- Covers: `ternary_to_null_coalescing`, `SlevomatCodingStandard.ControlStructures.RequireNullCoalesceOperator` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### expressions.comparison.loose @@ -2849,8 +3065,9 @@ A loose comparison, `==`, `!=` or `<>`, is written `===` or `!==`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\NoLooseComparisonsRule`, stage Structure +- Covers: `strict_comparison`, `SlevomatCodingStandard.Operators.DisallowEqualOperators` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### expressions.comparison.notEquals @@ -2861,8 +3078,9 @@ Not about whether the comparison is loose, which `expressions.comparison.loose` - By default `keep` - Takes: `'!='` (`!=`, never `<>`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `'!='` +- The standards: perCs `keep`, psr12 `keep`, nette `'!='`, symfony `'!='`, laravel `'!='` - Rule `DressCode\Rules\Expressions\NotEqualsNotationRule`, stage Structure +- Covers: `standardize_not_equals` ### expressions.comparison.yoda @@ -2870,8 +3088,9 @@ A Yoda comparison, the constant on its left side, a comparison of two variables - By default `keep` - Takes: `forbidden` (never there, the variable on the left and the constant on the right, `$a === 1`); `required` (always there, the constant on the left, `1 === $a`, so that an accidental assignment does not compile); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `required`, laravel `forbidden` - Rule `DressCode\Rules\Expressions\YodaRule`, stage Structure +- Covers: `yoda_style`, `SlevomatCodingStandard.ControlStructures.DisallowYodaComparison`, `SlevomatCodingStandard.ControlStructures.RequireYodaComparison` ### expressions.doubleNegation @@ -2879,8 +3098,9 @@ A Yoda comparison, the constant on its left side, a comparison of two variables - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Expressions\NoDoubleNegationsRule`, stage Structure +- Covers: `no_short_bool_cast` ### expressions.explicitPrecedence @@ -2888,8 +3108,9 @@ An operand where logical or bitwise operators are easy to misread is parenthesiz - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\ExplicitPrecedenceRequiredRule`, stage Structure +- Covers: `Generic.CodeAnalysis.RequireExplicitBooleanOperatorPrecedence` ### expressions.parenthesesAfterConstruct @@ -2897,8 +3118,9 @@ An operand where logical or bitwise operators are easy to misread is parenthesiz - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Expressions\UselessParenthesesAfterConstructRule`, stage Structure +- Covers: `include`, `no_unneeded_control_parentheses`, `SlevomatCodingStandard.ControlStructures.LanguageConstructWithParentheses`, `Squiz.Strings.EchoedStrings` ### expressions.ternary.ofTrueFalse @@ -2906,8 +3128,9 @@ An operand where logical or bitwise operators are easy to misread is parenthesiz - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\UselessTernaryOperatorRule`, stage Structure +- Covers: `SlevomatCodingStandard.ControlStructures.UselessTernaryOperator` ### expressions.ternary.short @@ -2915,8 +3138,9 @@ An operand where logical or bitwise operators are easy to misread is parenthesiz - By default `keep` - Takes: `required` (always there); `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\ShortTernaryRule`, stage Structure +- Covers: `ternary_to_elvis_operator`, `SlevomatCodingStandard.ControlStructures.DisallowShortTernaryOperator`, `SlevomatCodingStandard.ControlStructures.RequireShortTernaryOperator` ### expressions.wordLogicalOperator @@ -2924,8 +3148,9 @@ An operand where logical or bitwise operators are easy to misread is parenthesiz - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\LogicalOperatorNotationRule`, stage Structure +- Covers: `logical_operators`, `Squiz.Operators.ValidLogicalOperators` ## literals @@ -2935,8 +3160,9 @@ The syntax of an array. - By default `keep` - Takes: `short` (`[1, 2]`, never `array(1, 2)`); `long` (`array(1, 2)`, never `[1, 2]`); `keep` -- The standards: perCs `short`, psr12 `keep`, nette `short` +- The standards: perCs `short`, psr12 `keep`, nette `short`, symfony `short`, laravel `short` - Rule `DressCode\Rules\Arrays\ArraySyntaxRule`, stage Structure +- Covers: `array_syntax`, `Generic.Arrays.DisallowLongArraySyntax`, `Generic.Arrays.DisallowShortArraySyntax` ### literals.arraySyntax.list @@ -2944,8 +3170,9 @@ The syntax of a destructuring. - By default `keep` - Takes: `short` (`[$a, $b] =`, never `list($a, $b) =`); `long` (`list($a, $b) =`, never `[$a, $b] =`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `short` +- The standards: perCs `keep`, psr12 `keep`, nette `short`, symfony `keep`, laravel `short` - Rule `DressCode\Rules\Arrays\ArraySyntaxRule`, stage Structure +- Covers: `list_syntax`, `SlevomatCodingStandard.PHP.ShortList` ### literals.classNameInString @@ -2953,8 +3180,9 @@ The syntax of a destructuring. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ClassKeywordForStringRule`, stage Structure +- Covers: `class_keyword` - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` ### literals.concatenatedLiterals @@ -2963,8 +3191,9 @@ Two string literals concatenated on one line, which are one literal, `'ab'` for - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Literals\UselessStringConcatenationRule`, stage Structure +- Covers: `PhpCsFixerCustomFixers/no_superfluous_concatenation`, `no_useless_concat_operator`, `Generic.Strings.UnnecessaryStringConcat` ### literals.concatenatedLiteralsOverLines @@ -2972,8 +3201,9 @@ Whether two string literals concatenated over a line break are one literal too. - By default `no` - Takes: `yes`; `no` -- The standards: perCs `no`, psr12 `no`, nette `no` +- The standards: perCs `no`, psr12 `no`, nette `no`, symfony `no`, laravel `no` - Rule `DressCode\Rules\Literals\UselessStringConcatenationRule`, stage Structure +- Covers: `Generic.Strings.UnnecessaryStringConcat` ### literals.digitSeparator.fraction @@ -2981,8 +3211,9 @@ The separator grouping by three the digits of the fraction of a decimal number, - By default `keep` - Takes: `required` (always there); `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Literals\NumberNotationRule`, stage Structure +- Covers: `PhpCsFixerCustomFixers/numeric_literal_separator`, `numeric_literal_separator`, `SlevomatCodingStandard.Numbers.DisallowNumericLiteralSeparator`, `SlevomatCodingStandard.Numbers.RequireNumericLiteralSeparator` ### literals.digitSeparator.integer @@ -2990,8 +3221,9 @@ The separator grouping by three the digits of a decimal integer, or of the integ - By default `keep` - Takes: `required` (always there); `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Literals\NumberNotationRule`, stage Structure +- Covers: `PhpCsFixerCustomFixers/numeric_literal_separator`, `numeric_literal_separator`, `SlevomatCodingStandard.Numbers.DisallowNumericLiteralSeparator`, `SlevomatCodingStandard.Numbers.RequireNumericLiteralSeparator` ### literals.digitSeparatorMinDigits.fraction @@ -2999,8 +3231,9 @@ The number of digits from which `literals.digitSeparator.fraction` groups them. - By default `4` - Takes: a count from 1 -- The standards: perCs `4`, psr12 `4`, nette `20` +- The standards: perCs `4`, psr12 `4`, nette `20`, symfony `4`, laravel `4` - Rule `DressCode\Rules\Literals\NumberNotationRule`, stage Structure +- Covers: `SlevomatCodingStandard.Numbers.RequireNumericLiteralSeparator` ### literals.digitSeparatorMinDigits.integer @@ -3008,8 +3241,9 @@ The number of digits from which `literals.digitSeparator.integer` groups them. - By default `4` - Takes: a count from 1 -- The standards: perCs `4`, psr12 `4`, nette `7` +- The standards: perCs `4`, psr12 `4`, nette `7`, symfony `4`, laravel `4` - Rule `DressCode\Rules\Literals\NumberNotationRule`, stage Structure +- Covers: `SlevomatCodingStandard.Numbers.RequireNumericLiteralSeparator` ### literals.emptyStringConcatenation @@ -3017,8 +3251,9 @@ The concatenation with an empty string, which is a cast or nothing, `(string) $x - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Literals\UselessStringConcatenationRule`, stage Structure +- Covers: `PhpCsFixerCustomFixers/no_superfluous_concatenation`, `Generic.Strings.UnnecessaryStringConcat` ### literals.heredocWithoutInterpolation @@ -3026,8 +3261,9 @@ A heredoc with nothing to interpolate, which is written as a nowdoc. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `forbidden`, psr12 `keep`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `forbidden` - Rule `DressCode\Rules\Literals\NowdocForHeredocRule`, stage Structure +- Covers: `heredoc_to_nowdoc`, `SlevomatCodingStandard.PHP.RequireNowdoc` ### literals.implicitBackslash @@ -3035,8 +3271,9 @@ A backslash of a string that escapes nothing, written `\\` instead. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Literals\NoImplicitBackslashesRule`, stage Structure +- Covers: `escape_implicit_backslashes`, `string_implicit_backslashes` ### literals.quotes @@ -3044,8 +3281,9 @@ The quotes of a plain string, one without interpolation, without an escape seque - By default `keep` - Takes: `single` (`'text'` where double quotes gain nothing); `double` (`"text"` for every plain string, the backslashes of a single-quoted one escaped); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `single` +- The standards: perCs `keep`, psr12 `keep`, nette `single`, symfony `single`, laravel `single` - Rule `DressCode\Rules\Literals\StringQuotesRule`, stage Structure +- Covers: `single_quote`, `Squiz.Strings.DoubleQuoteUsage` ## comments @@ -3055,8 +3293,9 @@ An empty comment, which is removed. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `keep` - Rule `DressCode\Rules\Comments\NoEmptyCommentsRule`, stage Finishing +- Covers: `no_empty_comment`, `SlevomatCodingStandard.Commenting.EmptyComment` - Modifies comments ### comments.singleline @@ -3065,8 +3304,9 @@ The marker of a single-line comment. - By default `keep` - Takes: `'//'` (`//`, never `#`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `'//'` +- The standards: perCs `keep`, psr12 `keep`, nette `'//'`, symfony `'//'`, laravel `'//'` - Rule `DressCode\Rules\Comments\NoHashCommentsRule`, stage Finishing +- Covers: `single_line_comment_style`, `PEAR.Commenting.InlineComment` - Modifies comments ## phpdoc @@ -3077,8 +3317,9 @@ The doc comment stands above the attributes. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `required`, psr12 `keep`, nette `required` +- The standards: perCs `required`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\PhpdocAboveAttributesRule`, stage Structure +- Covers: `SlevomatCodingStandard.Attributes.RequireAttributeAfterDocComment` - Modifies comments ### phpdoc.annotation.casing @@ -3087,8 +3328,9 @@ The letter case of a known annotation. - By default `keep` - Takes: `canonical` (as it is known, `@inheritDoc`, `@phpstan-var`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `canonical` +- The standards: perCs `keep`, psr12 `keep`, nette `canonical`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\AnnotationCasingRule`, stage Structure +- Covers: `phpdoc_tag_casing`, `SlevomatCodingStandard.Commenting.AnnotationName` - Modifies comments ### phpdoc.annotation.constantVar @@ -3097,8 +3339,9 @@ A `@var` on a class constant that says nothing. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\UselessConstantVarAnnotationRule`, stage Finishing +- Covers: `SlevomatCodingStandard.TypeHints.UselessConstantTypeHint` - Modifies comments ### phpdoc.annotation.duplicateParam @@ -3107,7 +3350,7 @@ A second `@param` of one parameter. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\NoInvalidAnnotationsRule`, stage Structure ### phpdoc.annotation.duplicateReturn @@ -3116,8 +3359,9 @@ A second `@return` of a function. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\NoInvalidAnnotationsRule`, stage Structure +- Covers: `Squiz.Commenting.FunctionComment.DuplicateReturn` ### phpdoc.annotation.duplicateVar @@ -3125,8 +3369,9 @@ A second `@var` of a property. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\NoInvalidAnnotationsRule`, stage Structure +- Covers: `Squiz.Commenting.VariableComment` ### phpdoc.annotation.empty @@ -3134,8 +3379,9 @@ A `@param`, `@return`, `@var` or `@see` without content. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\NoInvalidAnnotationsRule`, stage Structure +- Covers: `Squiz.Commenting.VariableComment` ### phpdoc.annotation.extraParam @@ -3143,8 +3389,9 @@ A `@param` naming a parameter the function does not have. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\NoInvalidAnnotationsRule`, stage Structure +- Covers: `Squiz.Commenting.FunctionComment.ExtraParamComment` ### phpdoc.annotation.inlineVar @@ -3152,8 +3399,9 @@ An inline `@var` says what `assert($x instanceof Foo)` checks. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\AssertForInlineVarRule`, stage Structure +- Covers: `SlevomatCodingStandard.PHP.RequireExplicitAssertion` - Modifies comments - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3163,8 +3411,9 @@ An array, iterable or traversable declaration has an annotation saying what its - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Types\NativeTypeRequiredRule`, stage Structure +- Covers: `SlevomatCodingStandard.TypeHints.ParameterTypeHint`, `SlevomatCodingStandard.TypeHints.PropertyTypeHint`, `SlevomatCodingStandard.TypeHints.ReturnTypeHint` - Modifies comments ### phpdoc.annotation.nativeTypeOnly @@ -3173,8 +3422,9 @@ A `@param`, `@return` or `@var` without a description that says only the native - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `forbidden` - Rule `DressCode\Rules\PhpDoc\UselessTypeAnnotationsRule`, stage Structure +- Covers: `no_superfluous_phpdoc_tags`, `SlevomatCodingStandard.Commenting.UselessFunctionDocComment`, `SlevomatCodingStandard.TypeHints.ParameterTypeHint`, `SlevomatCodingStandard.TypeHints.PropertyTypeHint`, `SlevomatCodingStandard.TypeHints.ReturnTypeHint` - Modifies comments ### phpdoc.annotation.plainComment @@ -3183,8 +3433,9 @@ An annotation in a plain comment, `/* @var */` or `/**@var` without the blank, w - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\NoPlainPhpdocsRule`, stage Finishing +- Covers: `SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.InvalidCommentType` - Modifies comments - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3194,8 +3445,9 @@ The blank lines inside a doc comment. - By default `keep` - Takes: `trimmed` (none at the start or the end, at most one in a row inside); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `trimmed` +- The standards: perCs `keep`, psr12 `keep`, nette `trimmed`, symfony `trimmed`, laravel `trimmed` - Rule `DressCode\Rules\PhpDoc\PhpdocBlankLinesRule`, stage Finishing +- Covers: `phpdoc_trim`, `phpdoc_trim_consecutive_blank_line_separation` - Modifies comments ### phpdoc.empty @@ -3204,8 +3456,9 @@ An empty doc comment, which is removed. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Comments\NoEmptyCommentsRule`, stage Finishing +- Covers: `no_empty_phpdoc` - Modifies comments ### phpdoc.forbiddenAnnotations @@ -3214,8 +3467,9 @@ The annotations removed from doc comments, written with the `@`, such as `@autho - By default `keep` - Takes: a list of annotations, each with its `@`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `[@access, @author, @copyright, @created, @license, @package, @since, @subpackage, @todo, @version]` +- The standards: perCs `keep`, psr12 `keep`, nette `[@access, @author, @copyright, @created, @license, @package, @since, @subpackage, @todo, @version]`, symfony `keep`, laravel `[@access, @package, @subpackage]` - Rule `DressCode\Rules\PhpDoc\ForbiddenAnnotationsRule`, stage Structure +- Covers: `general_phpdoc_annotation_remove`, `phpdoc_no_access`, `phpdoc_no_package`, `SlevomatCodingStandard.Commenting.ForbiddenAnnotations` - Modifies comments ### phpdoc.forbiddenLines @@ -3224,8 +3478,9 @@ The patterns of the lines of a description removed from doc comments, such as `~ - By default `keep` - Takes: a list of regular expressions; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `['~^(?:(?!private|protected|static)\S+ )?(?:con|de)structor\.\z~i', '~^\S+ [gs]etter\.\z~i']` +- The standards: perCs `keep`, psr12 `keep`, nette `['~^(?:(?!private|protected|static)\S+ )?(?:con|de)structor\.\z~i', '~^\S+ [gs]etter\.\z~i']`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\ForbiddenPhpdocLinesRule`, stage Structure +- Covers: `SlevomatCodingStandard.Commenting.ForbiddenComments` - Modifies comments ### phpdoc.inheritdocOnly @@ -3234,8 +3489,9 @@ A doc comment of `@inheritDoc` alone. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\UselessInheritdocRule`, stage Structure +- Covers: `phpdoc_no_useless_inheritdoc`, `SlevomatCodingStandard.Commenting.UselessInheritDocComment` - Modifies comments ### phpdoc.property.lineSpan @@ -3244,8 +3500,9 @@ A property doc comment with a single annotation and no description. - By default `keep` - Takes: `collapsed` (on one line, `/** @var int */`); `expanded` (over three lines, the annotation on the one in the middle); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `collapsed` +- The standards: perCs `keep`, psr12 `keep`, nette `collapsed`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\PropertyPhpdocLineSpanRule`, stage Formatting +- Covers: `phpdoc_line_span`, `SlevomatCodingStandard.Commenting.DisallowOneLinePropertyDocComment`, `SlevomatCodingStandard.Commenting.RequireOneLinePropertyDocComment` - Modifies comments ### phpdoc.property.plainComment @@ -3254,8 +3511,9 @@ A plain comment describing a property, `//` or `/* */` where `/** */` belongs, r - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\NoPlainPhpdocsRule`, stage Finishing +- Covers: `Squiz.Commenting.VariableComment` - Modifies comments ### phpdoc.property.promoted @@ -3264,7 +3522,7 @@ The annotation of a promoted property. - By default `keep` - Takes: `'@var'` (a `@var` at the property, not a `@param` of the constructor); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `'@var'` +- The standards: perCs `keep`, psr12 `keep`, nette `'@var'`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\PromotedPropertyAnnotationPositionRule`, stage Structure - Modifies comments @@ -3274,8 +3532,9 @@ The `*` opening each line of a doc comment. - By default `keep` - Takes: `aligned` (each `*` below the first `*` of the opening `/**`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `aligned` +- The standards: perCs `keep`, psr12 `keep`, nette `aligned`, symfony `keep`, laravel `aligned` - Rule `DressCode\Rules\PhpDoc\PhpdocAlignmentRule`, stage Formatting +- Covers: `align_multiline_comment`, `phpdoc_indent`, `Squiz.Commenting.DocCommentAlignment` - Modifies comments ### phpdoc.types.array @@ -3284,8 +3543,9 @@ How an array type of a doc comment is written; `list` is a different type and - By default `keep` - Takes: `'array'` (the generic notation); `'T[]'` (the brackets); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\PhpdocTypeNotationRule`, stage Finishing +- Covers: `PhpCsFixerCustomFixers/phpdoc_array_style`, `PhpCsFixerCustomFixers/phpdoc_type_list`, `phpdoc_array_type`, `phpdoc_list_type`, `SlevomatCodingStandard.TypeHints.DisallowArrayTypeHintSyntax` - Modifies comments ### phpdoc.types.builtin @@ -3294,8 +3554,9 @@ How a built-in type in a doc comment is written, `int` and never `integer` or `I - By default `keep` - Takes: `canonical` (the short lowercase name of a built-in type, `never` for `no-return`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `canonical` +- The standards: perCs `keep`, psr12 `keep`, nette `canonical`, symfony `canonical`, laravel `canonical` - Rule `DressCode\Rules\PhpDoc\PhpdocTypeNotationRule`, stage Finishing +- Covers: `PhpCsFixerCustomFixers/phpdoc_type_list`, `phpdoc_list_type`, `phpdoc_scalar`, `phpdoc_types`, `SlevomatCodingStandard.TypeHints.LongTypeHints` - Modifies comments ### phpdoc.types.duplicate @@ -3304,8 +3565,9 @@ A type a union of a doc comment names twice, `int|integer`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\PhpdocTypeNotationRule`, stage Finishing +- Covers: `phpdoc_no_duplicate_types`, `phpdoc_types_no_duplicates` - Modifies comments ### phpdoc.types.nullable @@ -3314,7 +3576,7 @@ How a type of a doc comment of one type and `null` is written. - By default `keep` - Takes: `'?T'` (a single type with `null` written with `?`); `'T|null'` (a union with `null`, which `phpdoc.types.nullPosition` places); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\PhpdocTypeNotationRule`, stage Finishing - Modifies comments @@ -3324,8 +3586,9 @@ Where `null` stands in a union type of a doc comment. - By default `keep` - Takes: `last` (`int|string|null`); `first` (`null|int|string`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `last` +- The standards: perCs `keep`, psr12 `keep`, nette `last`, symfony `last`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\PhpdocTypeNotationRule`, stage Finishing +- Covers: `phpdoc_types_order`, `SlevomatCodingStandard.TypeHints.NullTypeHintOnLastPosition` - Modifies comments ### phpdoc.types.unionOrder @@ -3334,8 +3597,9 @@ The order of the types of a union type of a doc comment. - By default `keep` - Takes: `alphabetical` (the types beside `null` sorted by name, case-insensitively); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\PhpdocTypeNotationRule`, stage Finishing +- Covers: `phpdoc_types_order` - Modifies comments ## hygiene @@ -3346,8 +3610,9 @@ The sets of functions whose aliases are written by their canonical names. - By default `keep` - Takes: a list of `all` (every set below); `core` (the core functions, `sizeof()` is `count()`, `join()` is `implode()`); `imap` (`imap_*`); `ldap` (`ldap_*`); `mysqli` (`mysqli_*`); `pg` (`pg_exec()` is `pg_query()`); `oci` (`oci_*`); `odbc` (`odbc_*`); `openssl` (`openssl_*`); `sodium` (`sodium_*`); `ftp` (`ftp_quit()` is `ftp_close()`); `posix` (`posix_errno()` is `posix_get_last_error()`); `pcntl` (`pcntl_errno()` is `pcntl_get_last_error()`); `time` (`mktime()` and `gmmktime()` without arguments are `time()`); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `[core, imap, pg]` +- The standards: perCs `keep`, psr12 `keep`, nette `[core, imap, pg]`, symfony `keep`, laravel `[core, imap, pg]` - Rule `DressCode\Rules\Functions\NoAliasFunctionsRule`, stage Structure +- Covers: `no_alias_functions` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### hygiene.call_user_func @@ -3356,8 +3621,9 @@ The sets of functions whose aliases are written by their canonical names. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoCallUserFuncRule`, stage Structure +- Covers: `regular_callable_call` - Needs PHP >=7.0 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3367,7 +3633,7 @@ Whether a method the callable names as `[$this, 'run']`, `[self::class, 'make']` - By default `no` - Takes: `yes`; `no` -- The standards: perCs `no`, psr12 `no`, nette `no` +- The standards: perCs `no`, psr12 `no`, nette `no`, symfony `no`, laravel `no` - Rule `DressCode\Rules\Functions\NoCallUserFuncRule`, stage Structure - Needs PHP >=7.0 @@ -3377,8 +3643,9 @@ Whether a method the callable names as `[$this, 'run']`, `[self::class, 'make']` - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ClassNameNotationRule`, stage Structure +- Covers: `SlevomatCodingStandard.Classes.ModernClassNameReference` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### hygiene.classNameNotation.object @@ -3387,8 +3654,9 @@ Whether a method the callable names as `[$this, 'run']`, `[self::class, 'make']` - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ClassNameNotationRule`, stage Structure +- Covers: `get_class_to_class_keyword`, `SlevomatCodingStandard.Classes.ModernClassNameReference` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### hygiene.conversionFunction @@ -3399,8 +3667,9 @@ Not about which name of `floatval()` the code writes, `doubleval()` being an ali - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoConversionFunctionsRule`, stage Structure +- Covers: `modernize_types_casting` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### hygiene.countEmptyTest @@ -3409,7 +3678,7 @@ Not about which name of `floatval()` the code writes, `doubleval()` being an ali - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\UselessMeasuringCallsRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3420,7 +3689,7 @@ An argument PHP ignores or that repeats the default. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\RedundantArgumentsRule`, stage Structure - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3430,8 +3699,9 @@ An argument PHP ignores or that repeats the default. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoDirnameOfFileRule`, stage Structure +- Covers: `PhpCsFixerCustomFixers/no_useless_dirname_call`, `combine_nested_dirname`, `dir_constant` ### hygiene.is_null @@ -3439,8 +3709,9 @@ An argument PHP ignores or that repeats the default. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoIsNullRule`, stage Structure +- Covers: `is_null` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### hygiene.nestedDirname @@ -3449,7 +3720,7 @@ A `dirname()` of a `dirname()`, which is one call with the `levels` argument. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoDirnameOfFileRule`, stage Structure - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3459,7 +3730,7 @@ Two adjacent catches of one body, written as one `catch (A|B $e)`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\NoRepeatedCatchesRule`, stage Structure - Needs PHP >=7.1 @@ -3469,8 +3740,9 @@ Two adjacent catches of one body, written as one `catch (A|B $e)`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Variables\NoSeparateIssetsRule`, stage Structure +- Covers: `combine_consecutive_issets` ### hygiene.separateUnsets @@ -3478,8 +3750,9 @@ Two adjacent catches of one body, written as one `catch (A|B $e)`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Variables\NoSeparateUnsetsRule`, stage Structure +- Covers: `combine_consecutive_unsets` ### hygiene.settype @@ -3487,8 +3760,9 @@ Two adjacent catches of one body, written as one `catch (A|B $e)`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoSettypeRule`, stage Structure +- Covers: `set_type_to_cast` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### hygiene.strlenEmptyTest @@ -3497,8 +3771,9 @@ Two adjacent catches of one body, written as one `catch (A|B $e)`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\UselessMeasuringCallsRule`, stage Structure +- Covers: `PhpCsFixerCustomFixers/no_useless_strlen`, `string_length_to_empty` - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3508,8 +3783,9 @@ The variable of a `catch` that is never read, `catch (E)` written instead. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\UselessCatchVariableRule`, stage Structure +- Covers: `SlevomatCodingStandard.Exceptions.RequireNonCapturingCatch` - Needs PHP >=8.0 ### hygiene.uselessVersionCheck @@ -3518,18 +3794,19 @@ A check of the version of PHP every version the code runs on decides alike, `if - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\UselessVersionCheckRule`, stage Structure - Modifies comments ### hygiene.__invokeCall -`$f->__invoke($x)` is `$f($x)`. +`$f->__invoke($x)` is `$f($x)`, or the other way round for an object. - By default `keep` -- Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` -- Rule `DressCode\Rules\Functions\NoExplicitInvokeCallsRule`, stage Structure +- Takes: `forbidden` (never there); `required` (always there); `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` +- Rule `DressCode\Rules\Functions\InvokeCallNotationRule`, stage Structure +- Covers: `SlevomatCodingStandard.PHP.DisallowDirectMagicInvokeCall` ## mistakes @@ -3539,7 +3816,7 @@ A doc comment followed by another one, the first lost to reflection, which is on - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\NoConsecutivePhpdocsRule`, stage Finishing ### mistakes.continueInSwitch @@ -3548,8 +3825,9 @@ A `continue` that only leaves a `switch`, where `break` is meant, which is only - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\NoContinueInSwitchRule`, stage Structure +- Covers: `switch_continue_to_break`, `SlevomatCodingStandard.ControlStructures.DisallowContinueWithoutIntegerOperandInSwitch` ### mistakes.countOfUncountable @@ -3557,7 +3835,7 @@ A `continue` that only leaves a `switch`, where `break` is meant, which is only - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoUncountableCountsRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` @@ -3567,8 +3845,9 @@ A statement calling a debugging function to print, changed into a comment, `// v - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoDebugOutputRule`, stage Structure +- Covers: `PhpCsFixerCustomFixers/commented_out_function`, `Squiz.PHP.DiscouragedFunctions` - Modifies comments - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3578,8 +3857,9 @@ The debugging functions whose calls print. - By default `[print_r, var_dump, var_export]` - Takes: a list of names -- The standards: perCs `[print_r, var_dump, var_export]`, psr12 `[print_r, var_dump, var_export]`, nette `[print_r, var_dump, var_export]` +- The standards: perCs `[print_r, var_dump, var_export]`, psr12 `[print_r, var_dump, var_export]`, nette `[print_r, var_dump, var_export]`, symfony `[print_r, var_dump, var_export]`, laravel `[print_r, var_dump, var_export]` - Rule `DressCode\Rules\Functions\NoDebugOutputRule`, stage Structure +- Covers: `PhpCsFixerCustomFixers/commented_out_function` - Modifies comments ### mistakes.errorSuppression @@ -3588,7 +3868,7 @@ The `@` operator, which silences errors and is only reported. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\NoErrorSuppressionRule`, stage Structure ### mistakes.exceptionWhereThrowableBelongs @@ -3597,8 +3877,9 @@ The `@` operator, which silences errors and is only reported. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\ThrowableForExceptionRule`, stage Structure +- Covers: `SlevomatCodingStandard.Exceptions.ReferenceThrowableOnly` ### mistakes.finalParent @@ -3606,7 +3887,7 @@ A class extending a final one, which is only reported. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\NoFinalParentRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` @@ -3616,8 +3897,9 @@ The `global` statement, which is only reported. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Variables\NoGlobalStatementsRule`, stage Structure +- Covers: `Squiz.PHP.GlobalKeyword` ### mistakes.invisibleCharacter @@ -3625,8 +3907,9 @@ Non-breaking, zero-width and bidi characters, replaced in comments and escaped i - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Files\NoInvisibleCharactersRule`, stage Structure +- Covers: `non_printable_character` - Modifies comments ### mistakes.looseNumberComparison @@ -3635,7 +3918,7 @@ Non-breaking, zero-width and bidi characters, replaced in comments and escaped i - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\NoLooseNumberComparisonsRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` @@ -3645,7 +3928,7 @@ The return type `never` on a closure that always throws or exits, though its sig - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Types\NeverForThrowingFunctionRule`, stage Structure - Needs PHP >=8.1 @@ -3655,7 +3938,7 @@ The return type `never` of PHP 8.1 on a function that always throws or exits. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Types\NeverForThrowingFunctionRule`, stage Structure - Needs PHP >=8.1 @@ -3665,7 +3948,7 @@ The return type `never` on a method that always throws or exits, only where no d - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Types\NeverForThrowingFunctionRule`, stage Structure - Needs PHP >=8.1 @@ -3675,7 +3958,7 @@ A property written only in the constructor, or not at all. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ReadonlyForUnwrittenPropertyRule`, stage Structure - Needs PHP >=8.1 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3686,8 +3969,9 @@ A variable assigned twice in one expression, `$a = $a = 1`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Variables\NoRepeatedAssignmentsRule`, stage Structure +- Covers: `SlevomatCodingStandard.Variables.DuplicateAssignmentToVariable` ### mistakes.staticSetState @@ -3695,7 +3979,7 @@ A variable assigned twice in one expression, `$a = $a = 1`. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\StaticSetStateRequiredRule`, stage Structure ### mistakes.strictArgument @@ -3704,8 +3988,9 @@ A variable assigned twice in one expression, `$a = $a = 1`. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\StrictComparisonArgumentRequiredRule`, stage Structure +- Covers: `strict_param`, `SlevomatCodingStandard.Functions.StrictCall` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### mistakes.thisOutsideObject @@ -3714,8 +3999,9 @@ A variable assigned twice in one expression, `$a = $a = 1`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\NoThisOutsideObjectRule`, stage Structure +- Covers: `Squiz.Scope.StaticThisUsage` ### mistakes.trailingWhitespaceInString @@ -3723,8 +4009,9 @@ At the end of the lines of a multi-line string or inline HTML, which changes the - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Literals\NoTrailingWhitespaceInStringRule`, stage Structure +- Covers: `no_trailing_whitespace_in_string` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### mistakes.unimplementedAbstractMethod @@ -3733,7 +4020,7 @@ An inherited abstract method a class that is not abstract leaves unimplemented, - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\NoUnimplementedAbstractMethodsRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` @@ -3743,8 +4030,9 @@ A `catch` after one catching `Throwable` or a class it catches, which is only re - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\NoUnreachableCatchesRule`, stage Structure +- Covers: `SlevomatCodingStandard.Exceptions.DeadCatch` ### mistakes.__debugInfoReturningNull @@ -3752,7 +4040,7 @@ The `null` that `__debugInfo()` returns, which becomes `[]`. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\NoNullDebugInfoRule`, stage Structure - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3762,7 +4050,7 @@ The `null` that `__debugInfo()` returns, which becomes `[]`. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\SerializeMethodsRequiredRule`, stage Structure ## upgrading @@ -3773,7 +4061,7 @@ An attribute written for an annotation (`@ORM\Entity: ORM\Entity`). - By default `keep` - Takes: a map of names and patterns with `*` to an entry of the grammar of the map, an entry withdrawn with `keep`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\AttributeForAnnotationRule`, stage Structure - Read by rule `DressCode\Rules\Upgrading\ReplacedClassesRule`, which this decision does not turn on - Read by rule `DressCode\Rules\Upgrading\NoDeprecatedClassesRule`, which this decision does not turn on @@ -3786,7 +4074,7 @@ An attribute written for a member the class declares by convention. - By default `keep` - Takes: a map of names to an entry of the grammar of the map, an entry withdrawn with `keep`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\AttributeForMemberRule`, stage Structure - Read by rule `DressCode\Rules\Upgrading\NoDeprecatedClassesRule`, which this decision does not turn on - Needs PHP >=8.0 @@ -3798,9 +4086,10 @@ A class that may not be used, with what to do instead (`Acme\Legacy\Db: "use the - By default `keep` - Takes: a map of names to an entry of the grammar of the map, a name alone being an entry without one, an entry withdrawn with `keep`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\ForbiddenSymbolsRule`, stage Structure - Read by rule `DressCode\Rules\Upgrading\NoDeprecatedClassesRule`, which this decision does not turn on +- Read by rule `DressCode\Rules\Upgrading\UnseenReferencesRule`, which this decision does not turn on ### upgrading.api.forbiddenConstants @@ -3808,7 +4097,7 @@ A global constant that may not be used, with what to do instead (`E_STRICT: "it - By default `keep` - Takes: a map of names and patterns with `*` to an entry of the grammar of the map, a name alone being an entry without one, an entry withdrawn with `keep`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\ForbiddenSymbolsRule`, stage Structure ### upgrading.api.forbiddenFunctions @@ -3817,9 +4106,10 @@ A function that may not be called, with what to do instead (`[var_dump, acme_sen - By default `keep` - Takes: a map of names and patterns with `*` to an entry of the grammar of the map, a name alone being an entry without one, an entry withdrawn with `keep`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\ForbiddenSymbolsRule`, stage Structure - Read by rule `DressCode\Rules\Upgrading\NoDeprecatedFunctionsRule`, which this decision does not turn on +- Covers: `Generic.PHP.ForbiddenFunctions`, `Generic.PHP.SAPIUsage`, `Squiz.PHP.DiscouragedFunctions` ### upgrading.api.forbiddenMembers @@ -3827,7 +4117,7 @@ A constant, a method or a property that may not be used, with what to do instead - By default `keep` - Takes: a map of names to an entry of the grammar of the map, a name alone being an entry without one, an entry withdrawn with `keep`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\ForbiddenMembersRule`, stage Structure - Read by rule `DressCode\Rules\Upgrading\ReplacedClassesRule`, which this decision does not turn on - Read by rule `DressCode\Rules\Upgrading\ReplacedCallsRule`, which this decision does not turn on @@ -3840,7 +4130,7 @@ A call written as the template says (`Acme\Mail\Mailer::send($to, $body): send(n - By default `keep` - Takes: a map of names to an entry of the grammar of the map, an entry withdrawn with `keep`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\ReplacedCallsRule`, stage Structure - Rule `DressCode\Rules\Upgrading\ReplacedFunctionCallsRule`, stage Structure - Read by rule `DressCode\Rules\Upgrading\ReplacedMembersRule`, which this decision does not turn on @@ -3853,9 +4143,10 @@ A class written instead of another one, both fully qualified (`Acme\Old\Mailer: - By default `keep` - Takes: a map of names to an entry of the grammar of the map, an entry withdrawn with `keep`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\ReplacedClassesRule`, stage Structure - Read by rule `DressCode\Rules\Upgrading\NoDeprecatedClassesRule`, which this decision does not turn on +- Read by rule `DressCode\Rules\Upgrading\UnseenReferencesRule`, which this decision does not turn on - Modifies comments ### upgrading.api.replacedFunctions @@ -3864,9 +4155,10 @@ A function written instead of another one (`acme_send: Acme\Mail\send`). - By default `keep` - Takes: a map of names to an entry of the grammar of the map, an entry withdrawn with `keep`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\ReplacedFunctionsRule`, stage Structure - Read by rule `DressCode\Rules\Upgrading\NoDeprecatedFunctionsRule`, which this decision does not turn on +- Covers: `Generic.PHP.ForbiddenFunctions` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### upgrading.api.replacedMembers @@ -3875,7 +4167,7 @@ A constant, a method or a property written instead of another one of the class ( - By default `keep` - Takes: a map of names to an entry of the grammar of the map, an entry withdrawn with `keep`; `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\ReplacedMembersRule`, stage Structure - Read by rule `DressCode\Rules\Upgrading\ReplacedCallsRule`, which this decision does not turn on - Read by rule `DressCode\Rules\Upgrading\NoDeprecatedMembersRule`, which this decision does not turn on @@ -3888,7 +4180,7 @@ A constant, a method or a property written instead of another one of the class ( - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\OverrideAttributeRequiredRule`, stage Structure - Needs PHP >=8.3 - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` @@ -3899,7 +4191,7 @@ A constant, a method or a property written instead of another one of the class ( - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\SensitiveParameterRequiredRule`, stage Structure - Needs PHP >=8.2 @@ -3909,7 +4201,7 @@ The names of the parameters holding a secret, whatever their letter case and und - By default `[password, passphrase, secret, apiKey, privateKey]` - Takes: a list of names -- The standards: perCs `[password, passphrase, secret, apiKey, privateKey]`, psr12 `[password, passphrase, secret, apiKey, privateKey]`, nette `[password, passphrase, secret, apiKey, privateKey]` +- The standards: perCs `[password, passphrase, secret, apiKey, privateKey]`, psr12 `[password, passphrase, secret, apiKey, privateKey]`, nette `[password, passphrase, secret, apiKey, privateKey]`, symfony `[password, passphrase, secret, apiKey, privateKey]`, laravel `[password, passphrase, secret, apiKey, privateKey]` - Rule `DressCode\Rules\Functions\SensitiveParameterRequiredRule`, stage Structure - Needs PHP >=8.2 @@ -3919,7 +4211,7 @@ A class with `__toString()` declares it. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\StringableRequiredRule`, stage Structure - Needs PHP >=8.0 @@ -3929,7 +4221,7 @@ A class whose declaration is `@deprecated`, replaced by the class the deprecatio - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\NoDeprecatedClassesRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` - Modifies comments @@ -3941,7 +4233,7 @@ A function whose declaration is `@deprecated`, replaced by the function the depr - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\NoDeprecatedFunctionsRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3952,7 +4244,7 @@ A constant, method or property whose declaration is `@deprecated`, replaced by t - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\NoDeprecatedMembersRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3963,7 +4255,7 @@ A constant, method or property whose declaration is `@deprecated`, replaced by t - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\ArrayFirstLastNotationRule`, stage Structure - Needs PHP >=8.5 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3974,7 +4266,7 @@ A constant, method or property whose declaration is `@deprecated`, replaced by t - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\ArraySearchForFilterRule`, stage Structure - Rule `DressCode\Rules\ControlFlow\ArraySearchForForeachRule`, stage Structure - Needs PHP >=8.4 @@ -3986,7 +4278,7 @@ A constant, method or property whose declaration is `@deprecated`, replaced by t - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Arrays\NoManualListTestsRule`, stage Structure - Needs PHP >=8.1 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -3997,7 +4289,7 @@ A constant, method or property whose declaration is `@deprecated`, replaced by t - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\ClampForMinMaxRule`, stage Structure - Needs PHP >=8.6 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4008,7 +4300,7 @@ A constant, method or property whose declaration is `@deprecated`, replaced by t - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\GetDebugTypeForTernaryRule`, stage Structure - Needs PHP >=8.0 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4019,7 +4311,7 @@ A constant, method or property whose declaration is `@deprecated`, replaced by t - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\JsonValidateForDecodeRule`, stage Structure - Needs PHP >=8.3 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4030,7 +4322,7 @@ A constant, method or property whose declaration is `@deprecated`, replaced by t - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\RoundingModeNotationRule`, stage Structure - Needs PHP >=8.4 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4041,8 +4333,9 @@ A constant, method or property whose declaration is `@deprecated`, replaced by t - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoManualSubstringTestsRule`, stage Structure +- Covers: `modernize_strpos` - Needs PHP >=8.0 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4052,7 +4345,7 @@ Whether the code follows the upgrading files of the installed packages, their ma - By default `no` - Takes: `yes`; `no` -- The standards: perCs `no`, psr12 `no`, nette `no` +- The standards: perCs `no`, psr12 `no`, nette `no`, symfony `no`, laravel `no` - Rule `DressCode\Rules\Upgrading\ReplacedClassesRule`, stage Structure - Rule `DressCode\Rules\Upgrading\ReplacedFunctionsRule`, stage Structure - Rule `DressCode\Rules\Upgrading\ReplacedMembersRule`, stage Structure @@ -4070,8 +4363,9 @@ The backtick operator PHP 8.5 deprecated, written as a `shell_exec()` call. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `keep` - Rule `DressCode\Rules\Expressions\NoBacktickOperatorsRule`, stage Structure +- Covers: `backtick_to_shell_exec` ### upgrading.php.caseSemicolon @@ -4079,8 +4373,9 @@ The backtick operator PHP 8.5 deprecated, written as a `shell_exec()` call. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden` +- The standards: perCs `forbidden`, psr12 `forbidden`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\ControlFlow\SwitchCaseNotationRule`, stage Structure +- Covers: `switch_case_semicolon_to_colon`, `PSR2.ControlStructures.SwitchDeclaration` ### upgrading.php.constructorReturnValue @@ -4088,7 +4383,7 @@ A `return` with a value in a constructor or destructor, deprecated by PHP 8.6. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\NoConstructorReturnValuesRule`, stage Structure ### upgrading.php.csvEscapeArgument @@ -4097,7 +4392,7 @@ The `escape` argument of `fgetcsv()` and kin written out, since PHP 8.4 deprecat - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\CsvEscapeArgumentRequiredRule`, stage Structure - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4107,12 +4402,13 @@ A function, a constant or a member PHP deprecated, written as what replaces it a - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\ReplacedMembersRule`, stage Structure - Rule `DressCode\Rules\Upgrading\ReplacedCallsRule`, stage Structure - Rule `DressCode\Rules\Upgrading\ReplacedFunctionCallsRule`, stage Structure - Rule `DressCode\Rules\Upgrading\ForbiddenSymbolsRule`, stage Structure - Rule `DressCode\Rules\Upgrading\ForbiddenMembersRule`, stage Structure +- Covers: `Generic.PHP.DeprecatedFunctions` - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` ### upgrading.php.deprecatedArgument @@ -4121,7 +4417,7 @@ An argument whose value PHP deprecated is reported, `E_USER_ERROR` given to `tri - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Upgrading\NoDeprecatedPhpArgumentsRule`, stage Structure ### upgrading.php.dollarBraceInterpolation @@ -4130,8 +4426,9 @@ An argument whose value PHP deprecated is reported, `E_USER_ERROR` given to `tri - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `keep` - Rule `DressCode\Rules\Literals\NoDollarBraceInterpolationsRule`, stage Structure +- Covers: `simple_to_complex_string_variable` ### upgrading.php.dynamicProperty @@ -4139,7 +4436,7 @@ A property written to an object whose class does not declare it, deprecated by P - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\NoDynamicPropertiesRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` @@ -4149,7 +4446,7 @@ A property written to an object whose class does not declare it, deprecated by P - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Arrays\NoFloatArrayKeysRule`, stage Structure ### upgrading.php.implicitNullable @@ -4158,8 +4455,9 @@ A parameter type made nullable by its default `null` alone, `int $x = null`, dep - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `forbidden` +- The standards: perCs `keep`, psr12 `keep`, nette `forbidden`, symfony `forbidden`, laravel `forbidden` - Rule `DressCode\Rules\Types\NullableTypeForDefaultNullRule`, stage Structure +- Covers: `nullable_type_declaration_for_default_null_value`, `SlevomatCodingStandard.TypeHints.NullableTypeForNullDefaultValue` ### upgrading.php.nullArgument @@ -4167,7 +4465,7 @@ A parameter type made nullable by its default `null` alone, `int $x = null`, dep - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NoNullArgumentsRule`, stage Structure - Needs the types of the code, so it takes effect only with `typeAnalysis: phpstan` @@ -4177,7 +4475,7 @@ A parameter type made nullable by its default `null` alone, `int $x = null`, dep - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Arrays\NoNullArrayKeysRule`, stage Structure - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4187,7 +4485,7 @@ Names PHP 8.6 reserved, in declarations and imports. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Namespaces\NoReservedNamesRule`, stage Structure ### upgrading.php.returnInFinally @@ -4196,7 +4494,7 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\NoReturnsInFinallyRule`, stage Structure ### upgrading.php.sleepAndWakeup @@ -4205,8 +4503,9 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `forbidden` (never there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\NoSleepAndWakeupRule`, stage Structure +- Covers: `modern_serialization_methods` ### upgrading.phpdoc.deprecated @@ -4214,7 +4513,7 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\PhpDoc\DeprecatedAttributeForAnnotationRule`, stage Structure - Needs PHP >=8.4 - Modifies comments @@ -4226,8 +4525,9 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ReadonlyForAnnotationRule`, stage Structure +- Covers: `phpdoc_readonly_class_comment_to_keyword` - Needs PHP >=8.1 - Modifies comments - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4238,7 +4538,7 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\CloneWithNotationRule`, stage Structure - Needs PHP >=8.5 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4249,7 +4549,7 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ClassConstantForConstantCallRule`, stage Structure - Needs PHP >=8.3 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4260,7 +4560,7 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\CallableNotationRule`, stage Structure - Rule `DressCode\Rules\Functions\FirstClassCallableForStringRule`, stage Structure - Needs PHP >=8.1 @@ -4272,7 +4572,7 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\MatchForSwitchRule`, stage Structure - Needs PHP >=8.0 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4283,7 +4583,7 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Functions\NewInitializerForNullDefaultRule`, stage Structure - Needs PHP >=8.1 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4294,8 +4594,9 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\NullsafeForGuardedAccessRule`, stage Structure +- Covers: `SlevomatCodingStandard.ControlStructures.RequireNullSafeObjectOperator` - Needs PHP >=8.0 ### upgrading.syntax.octalPrefix @@ -4304,8 +4605,9 @@ A `return` inside `finally`, which PHP 8.6 deprecated. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `required` +- The standards: perCs `keep`, psr12 `keep`, nette `required`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Literals\OctalNotationRule`, stage Structure +- Covers: `octal_notation` - Needs PHP >=8.1 ### upgrading.syntax.pipe @@ -4314,7 +4616,7 @@ Calls nested one in another, each handing its result to the next, written with t - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Expressions\PipeForNestedCallsRule`, stage Structure - Needs PHP >=8.5 @@ -4324,7 +4626,7 @@ The calls a nest has at least for the pipe operator to be written. - By default `3` - Takes: a count from 2 -- The standards: perCs `3`, psr12 `3`, nette `3` +- The standards: perCs `3`, psr12 `3`, nette `3`, symfony `3`, laravel `3` - Rule `DressCode\Rules\Expressions\PipeForNestedCallsRule`, stage Structure - Needs PHP >=8.5 @@ -4334,8 +4636,9 @@ A constructor parameter only assigned to a property. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\PromotedPropertyForAssignmentRule`, stage Structure +- Covers: `SlevomatCodingStandard.Classes.RequireConstructorPropertyPromotion` - Needs PHP >=8.0 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4345,7 +4648,7 @@ A class whose every property is readonly. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Classes\ReadonlyClassForReadonlyPropertiesRule`, stage Structure - Needs PHP >=8.2 @@ -4355,7 +4658,7 @@ A class whose every property is readonly. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\Arrays\SpreadForArrayMergeRule`, stage Structure - Needs PHP >=8.1 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` @@ -4366,11 +4669,29 @@ A class whose every property is readonly. - By default `keep` - Takes: `required` (always there); `keep` -- The standards: perCs `keep`, psr12 `keep`, nette `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` - Rule `DressCode\Rules\ControlFlow\ThrowExpressionForNullGuardRule`, stage Structure - Needs PHP >=8.0 - May have risky fixes, made once the decision is named in `fixRisky` or with `--fix-risky` +### upgrading.unseen.classNameString + +A string holding the name of a class `replacedClasses` or `forbiddenClasses` names, which no rename of the class reaches. + +- By default `keep` +- Takes: `reported` (reported where it stands: the string `'Acme\Old'` naming a class the maps rename or forbid); `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` +- Rule `DressCode\Rules\Upgrading\UnseenReferencesRule`, stage Structure + +### upgrading.unseen.dynamicAccess + +A member, a class or a function reached by a name the code computes, which no map of upgrading data matches. + +- By default `keep` +- Takes: `reported` (reported where it stands: `$object->$method()`, `new $class`, `call_user_func($callback)`); `keep` +- The standards: perCs `keep`, psr12 `keep`, nette `keep`, symfony `keep`, laravel `keep` +- Rule `DressCode\Rules\Upgrading\UnseenReferencesRule`, stage Structure + ## namespaces ### namespaces.constants @@ -4379,7 +4700,7 @@ The constants the namespaces of the project declare, each of them listed once th - A fact of the project, which its rule guards - Takes: a list of names -- The standards: perCs `[]`, psr12 `[]`, nette `[]` +- The standards: perCs `[]`, psr12 `[]`, nette `[]`, symfony `[]`, laravel `[]` - Rule `DressCode\Rules\Namespaces\NoUnlistedNamespacedDeclarationsRule`, stage Structure ### namespaces.functions @@ -4388,5 +4709,5 @@ The functions the namespaces of the project declare, each of them listed once th - A fact of the project, which its rule guards - Takes: a list of names -- The standards: perCs `[]`, psr12 `[]`, nette `[]` +- The standards: perCs `[]`, psr12 `[]`, nette `[]`, symfony `[]`, laravel `[]` - Rule `DressCode\Rules\Namespaces\NoUnlistedNamespacedDeclarationsRule`, stage Structure diff --git a/docs/reference/generate.php b/docs/reference/generate.php index 137af10a48..04c8a82bad 100644 --- a/docs/reference/generate.php +++ b/docs/reference/generate.php @@ -6,7 +6,7 @@ */ use DressCode\{Config, DecisionKind, RuleInfo}; -use DressCode\Config\{Catalogue, ConfigResolver, NeonReader, PluginRegistry}; +use DressCode\Config\{Catalogue, ConfigResolver, CorePlugin, NeonReader, PluginRegistry}; use DressCode\Testing\RuleTester; require __DIR__ . '/../../vendor/autoload.php'; @@ -66,7 +66,7 @@ function formatValue(DressCode\Value $value): string $catalogue = $resolver->getCatalogue(); $riskyDecisions = findRiskyDecisions(); $standards = []; -foreach (['perCs', 'psr12', 'nette'] as $standard) { +foreach (CorePlugin::Standards as $standard) { $standards[$standard] = $resolver->resolve(new Config(use: ["dresscode/$standard"]), Config::DefaultPhpVersion)->decisions; } @@ -162,6 +162,10 @@ function formatValue(DressCode\Value $value): string $facts[] = 'Read by rule `' . $rule . '`, which this decision does not turn on'; } + if ($covers = $registry->translator->findForeignNames([$path])) { + $facts[] = 'Covers: ' . implode(', ', array_map(fn($a) => "`$a`", $covers)); + } + if (isset($phpNeeds[$path])) { $facts[] = "Needs PHP $phpNeeds[$path]"; } @@ -187,7 +191,7 @@ function formatValue(DressCode\Value $value): string $presets = $registry->presets; $out = "# Presets\n\nGenerated by `composer reference` from the preset files; do not edit. " . count($presets) . " presets.\n\n" - . "A project takes one of the standards (`dresscode/perCs`, `dresscode/psr12`, `dresscode/nette`)\n" + . 'A project takes one of the standards (`dresscode/' . implode('`, `dresscode/', CorePlugin::Standards) . "`)\n" . "as its base, and the description of every other preset says what it adds to it.\n\n" . "| Preset | Builds on | |\n|---|---|---|\n"; foreach ($presets as $name => $file) { diff --git a/docs/reference/presets.md b/docs/reference/presets.md index 48673b774a..ddb79f56bb 100644 --- a/docs/reference/presets.md +++ b/docs/reference/presets.md @@ -1,8 +1,8 @@ # Presets -Generated by `composer reference` from the preset files; do not edit. 9 presets. +Generated by `composer reference` from the preset files; do not edit. 11 presets. -A project takes one of the standards (`dresscode/perCs`, `dresscode/psr12`, `dresscode/nette`) +A project takes one of the standards (`dresscode/perCs`, `dresscode/psr12`, `dresscode/nette`, `dresscode/symfony`, `dresscode/laravel`) as its base, and the description of every other preset says what it adds to it. | Preset | Builds on | | @@ -10,6 +10,8 @@ as its base, and the description of every other preset says what it adds to it. | `dresscode/perCs` | `dresscode/psr12` | PER Coding Style 3.1 | | `dresscode/psr12` | | PSR-12 Extended Coding Style | | `dresscode/nette` | `dresscode/perCs`, `dresscode/cleanup`, `dresscode/types`, `dresscode/correctness` | Nette Coding Standard | +| `dresscode/symfony` | `dresscode/perCs` | Symfony Coding Standards | +| `dresscode/laravel` | `dresscode/perCs` | Laravel | | `dresscode/cleanup` | | Cleanup | | `dresscode/compilerOptimizations` | | Compiler optimizations | | `dresscode/correctness` | | Correctness | diff --git a/docs/skills/writing-rules.md b/docs/skills/writing-rules.md new file mode 100644 index 0000000000..b0762cfc13 --- /dev/null +++ b/docs/skills/writing-rules.md @@ -0,0 +1,119 @@ +# Writing a rule, or porting one from another tool + +A procedure for an agent that writes a rule for DressCode, new or ported from a sniff of PHP_CodeSniffer or a fixer +of PHP CS Fixer. It decides and orders; what a rule may and must do is the contract the manual states once, and this +guide links to it rather than repeat it: + +- [custom-rule](https://dresscode.run/custom-rule), a rule from its fixture to a project that uses it; +- [rule-contract](https://dresscode.run/rule-contract), the contract: decisions, values, report before mutation, + risk, analyses; +- [whitespace-rules](https://dresscode.run/whitespace-rules), a rule of the whitespace, which claims and does not + write; +- [testing-rules](https://dresscode.run/testing-rules), fixtures and `RuleTester`; +- [porting-rules](https://dresscode.run/porting-rules), the specification taken from the tests of the foreign rule. + +The engine itself is `docs/internals.md`; read the part a rule touches before writing one. + + +## 0. Is there anything to write? + +`dresscode catalogue --format json` lists for every decision the foreign names it covers (`covers`). A sniff or +a fixer named there is covered: write nothing, translate the configuration with `dresscode migrate config`. + + +## 1. The fixture first + +Write `fixtures//basic.code` before any code (`tests/DressCode/Rules/fixtures//` for a rule of the core): +the code before the fix, every case the foreign rule's tests have, and the ones a tree sees that a token stream does not +(a comment inside the construct, the construct over several lines, the alternative syntax, markup around it). Then +`basic.expected` (omit it where the fix changes nothing) and `basic.violations`, `line: message` per report, two +reports on one line being two lines. `values.neon` beside them gives the decision its value, written as a configuration +nests it (`project:` and under it `key: required`), or the rule does nothing; a comment opening a fixture can say more +(`// risky` for a run that makes risky fixes, `// php 8.4`, `// lineLength 80`). The slug is the class without `Rule`, +its first letter in lower case. + + +## 2. Choose the shape + +Ask in this order and stop at the first yes: + +1. **Is it only the whitespace between two tokens** (a space, a line break, blank lines)? A `GapRule`, in + `Stage::Formatting`, made of claims alone: `getClaims()` returns node class => slot => `[before, after]`, each a + `Claim` or null. It never writes a token. Archetype: `src/Rules/ControlFlow/SwitchCaseSpacingRule.php`. +2. **Is it a comment or a doc comment?** A `NodeRule` visiting `Token::class`, in `Stage::Finishing`, with + `modifiesComments: true` where it changes one. Archetype: `src/Rules/Comments/NoHashCommentsRule.php`. +3. **Does it rewrite code?** A `NodeRule` in `Stage::Structure` visiting the node classes it rewrites. Archetype: + `src/Rules/Expressions/NoDoubleNegationsRule.php`. +4. **Does it only report?** The same, every report with `fixable: false`, and its description says "which is only + reported". Archetype: `src/Rules/Variables/NoGlobalStatementsRule.php`. + +A rule that would both claim whitespace and change tokens is two rules. + + +## 3. Name the decision + +A user never names a rule, only the decision it enforces. In a project it is `project.`, in a plugin +`
.`; the key names the thing in the code and its value says how it is written (`required`, +`forbidden`, two shapes by their examples). A foreign option that chose between two forms is the value, one that +turned the rule on and off is `keep` against the value, a list of exceptions is a parameter. An option that only +worked around a weakness of tokens is dropped. The vocabulary is in `AGENTS.md` (Conventions) and on the page +[extending](https://dresscode.run/extending) of the manual. + + +## 4. Write the rule in this order + +1. `getDecisions()`: `new Decision($path, $domain, $description)`, the domain one of `DressCode\Domains\*` or + `Domain::state('forbidden')` and `Domain::state('required')` for a thing that must not or must be there; the + description English, capitalized, no trailing period. +2. `getVisitedNodes()`, or `getClaims()` for a gap rule. The node classes are in `PhpSyntax\Nodes`, `Nodes\Expression`, + `Nodes\Statement`, `Nodes\Member` and `Nodes\Scalar` of the PhpSyntax package, each with its slots in its `Slots` + constant, and the page `nodes` of its manual describes them. What a foreign rule tells by the next token is often + the class of the node: `static::A`, `static::a()` and `static::$a` are `ClassConstantFetchNode`, + `StaticMethodCallNode` and `StaticPropertyFetchNode`, so a foreign loop over tokens is a question about a class or + a slot. +3. `enter()`: one `if` of questions to the tree, shortest first, ending with `!$context->report($at, $message)`, + which returns false where a comment silenced the place or the run does not take the fix. `$at` is the node or token + the problem is at: its line is the line of the report, which a `dresscode:ignore` and the baseline match. +4. Ask whether the fix changes what the code does where the file does not show it: a child class that declares the + constant again (`static::A` is not `self::A` there), a closure bound later, a function of the namespace. Where it + may, report with `risk: Risk::BehaviorChanges` (or the `Risk` that would decide it) and a `because:`; where the tree + tells it cannot (a final class), the fix is safe. A foreign rule that called its fix safe is no answer. +5. The fix after the condition, never before it: a slot assigned, `replaceWith()`, `setValue()`, a `Builder` for new + code. Writing `$name->text` or `setValue()` keeps the comments around the node; a node put into a slot is taken + with `withoutEdgeTrivia()`. +6. `configure(Values $values)` reads the values once, where the rule has more than its one requirement. + +Every line answers a question about the code. A line asking what may stand between two tokens is a translated line, +not a ported one: delete it. A rule much over a hundred lines does several things or still thinks in tokens. + + +## 5. Run it + +```php +Assert::noError(fn() => DressCode\Testing\RuleTester::run(AcmeRule::class, __DIR__ . '/fixtures/acme')); +``` + +`RuleTester::run()` throws `DressCode\Testing\TestFailure` with a diff where a fixture fails. It compares the output and +the reports and checks the contract: the output is a fixpoint, no comment is +lost, `dresscode:ignoreFile` is honoured, nothing changed without a report. Then PHPStan at level 8. Read a failure by +its symptom: + +| what you see | what is wrong | +|---|---| +| `It changed the file although report() returned false` | the fix stands outside the condition ending with `report()` | +| `It changed the file after a report with fixable: false` | a report-only occurrence is fixed after all | +| `It reported an occurrence it then left unfixed without saying fixable: false` | the rule reports what it cannot fix | +| `It asks for analysis X, which it does not name` | `analyses: [X::class]` missing in `#[RuleInfo]`, a helper's included | +| `does not converge` | the rule rewrites its own output: it is not idempotent | +| the output loses a space or a line ending | a node written into a slot kept the trivia of its edges: take it with `withoutEdgeTrivia()` | +| `The node does not belong to the file of the analysis` | the rule removed or replaced an ancestor of the node it was given; visit the ancestor instead | + + +## 6. Porting: what does not carry over + +- **The code.** Only the behaviour and the test cases; mark fixtures taken from a foreign project with their origin and + licence in their header. +- **The foreign name.** `phpcs:ignore Acme.Sniff.Name` silences a decision of the core, never a rule of a plugin; the + user rewrites such comments with the path of the new decision, which `dresscode migrate suppressions` lists. +- **A risky fix of the foreign rule.** Asked of the tree (`ExpressionNode::isRepeatableRead()`, the types), the fix + may be safe; where the code cannot tell, it is reported with `risk:` and the `Risk` that would decide it. diff --git a/readme.md b/readme.md index b472f1c3cb..c58881b88e 100644 --- a/readme.md +++ b/readme.md @@ -11,7 +11,7 @@ ✅ Fixes style, [upgrades PHP and libraries](#upgrading-code-to-newer-php-from-80-to-86), in one run
✅ Changes only what it fixes, [not a byte more](#changes-only-what-it-touches-a-lossless-syntax-tree-instead-of-a-token-array)
-✅ [PER Coding Style, PSR-12, Nette](#coding-standards-and-decisions-per-coding-style-31-psr-12-nette) built in
+✅ [PER Coding Style, PSR-12, Nette, Symfony, Laravel](#coding-standards-and-decisions-per-coding-style-31-psr-12-nette-symfony-laravel) built in
✅ [More of PER Coding Style and PSR-12](#how-much-of-the-standard-per-coding-style-and-psr-12-against-php-cs-fixer-and-php_codesniffer) than PHP CS Fixer or PHP_CodeSniffer fixes
✅ [Made for AI coding agents](#made-for-ai-coding-agents-claude-code-and-codex): it tells the agent what it changed @@ -45,7 +45,8 @@ Installation and first run ========================== **1️⃣ Install it: `composer global require dresscode/dresscode`**
-**2️⃣ Check your code and fix it: `dresscode check`, `dresscode fix`** +**2️⃣ Have a configuration made to measure: `dresscode init`**
+**3️⃣ Check your code and fix it: `dresscode check`, `dresscode fix`** DressCode is a tool, not a library, so it does not have to be installed in the project it checks, and a global installation is the simplest way; just make sure the directory of global Composer binaries is in your @@ -85,9 +86,11 @@ A clean run ends with `OK 214 files, all up to the dress code`, and the exit co of the configuration. DressCode has no style of its own, so without a configuration it does not start: a silent default would -rewrite nearly every line of a project written another way. The configuration is `dresscode.neon` in the -root of the project, or `dresscode.php` with the same keys; to just try the tool, name a standard instead -with `dresscode check src --use perCs`: +rewrite nearly every line of a project written another way. `dresscode init` measures your code (the +indentation, the quotes, where a condition spread over lines begins and ends) and writes `dresscode.neon` +into the root of the project; to just try the tool, name a standard instead with +`dresscode check src --use perCs`. The configuration can also be written by hand, in NEON or as +`dresscode.php` with the same keys: ```neon use: @@ -137,8 +140,8 @@ of an upgrade contains the upgrade and nothing else.   -Coding standards and decisions: PER Coding Style 3.1, PSR-12, Nette -=================================================================== +Coding standards and decisions: PER Coding Style 3.1, PSR-12, Nette, Symfony, Laravel +===================================================================================== DressCode has over two hundred rules, and you never name one. The configuration says how your code is written, thing by thing: every key names a thing in the code, and its value says how it is written. @@ -174,7 +177,9 @@ you can answer right away. |---|---| | `perCs` | [PER Coding Style 3.1](https://www.php-fig.org/per/coding-style/) in full, the successor of PSR-12 | | `psr12` | [PSR-12](https://www.php-fig.org/psr/psr-12/), section by section | -| `nette` | [Nette Coding Standard](https://doc.nette.org/en/contributing/coding-standard), PER Coding Style with tabs and a few departures | +| `nette` | [Nette Coding Standard](https://doc.nette.org/contributing/coding-standard), PER Coding Style with tabs and a few departures | +| `symfony` | Symfony Coding Standards, as the `@Symfony` set of PHP CS Fixer has them | +| `laravel` | the style [Laravel Pint](https://laravel.com/docs/pint) writes by default, its `laravel` preset | A standard is a file of the same shape as yours, so you can read in it what it decides and why, and your file has the last word on every key. Where the standards differ, they give the same key different values, @@ -424,9 +429,10 @@ None of this is a search and replace: an annotation became an attribute along wi of a parameter moved behind `??` and a flag became a named argument. And DressCode knows that `$form` is a form and `$this` a presenter, because it asks PHPStan about the types. -The set `deprecations` lets the data of the installed packages in, and naming the package in `use` brings -its rules too. Most rewrites ask PHPStan about the [types](#types-from-phpstan); without them only classes, -functions and annotations are fixed: +`vendor/bin/dresscode upgrade today` makes the upgrade with no configuration: it takes the installed +packages with their data and their rules, asks PHPStan about the [types](#types-from-phpstan) and leaves +the style of the code as it is. To have every check watch for the old API too, name the package in `use` +next to the set `deprecations`; without the types only classes, functions and annotations are fixed: ```neon typeAnalysis: phpstan @@ -462,6 +468,51 @@ missing.   +Migrating from PHP CS Fixer, PHP_CodeSniffer and Laravel Pint +============================================================= + +DressCode knows not only its own decisions, but also the names their rules have in PHP CS Fixer, +PHP_CodeSniffer and Slevomat. So the move takes three steps, and you can stop after any of them. + +First, leave the code as it is. Comments `// phpcs:ignore`, `phpcs:disable`, `phpcs:enable`, +`phpcs:ignoreFile` and the annotation `@phpcsSuppress` keep working: DressCode translates the foreign rule +name to its decisions, and the suppression holds. + +Second, translate the configuration. `migrate config` finds the one the project has, PHP_CodeSniffer, PHP CS Fixer +or Laravel Pint, writes its equivalent to `dresscode.neon`, and tells you what it could not carry over: + +```shell +dresscode migrate config +``` + +``` +Found `phpcs.xml` of PHP_CodeSniffer, run by `composer cs`. +Written `dresscode.neon`. + +Read 4 rules; set 4 decisions and 1 preset, the rules translated; `dresscode init` measures the code instead. + `SlevomatCodingStandard.Namespaces.UnusedUses` without `searchAnnotations` removes an import only a doc comment uses; DressCode keeps it. + No DressCode rule covers `Squiz.Commenting.FunctionComment`. + +12 phpcs suppression comments: `dresscode migrate suppressions` rewrites them. +``` + +`.php-cs-fixer.dist.php` is translated the same way, as long as PHP CS Fixer is still installed in the +project, because that file is PHP which has to run; so `migrate config` asks before it runs one it found by itself. + +Third, rewrite the comments when you like: `dresscode migrate suppressions src tests` turns +`phpcs:ignore SlevomatCodingStandard.Namespaces.UnusedUses` into `dresscode:ignore imports.unused`. + +**Laravel Pint** runs PHP CS Fixer, with a command line of its own and presets of its own, `laravel` the +default one. A project on that preset has nothing to translate: DressCode has the style as the standard +`laravel`, and `dresscode init` in a project with Pint writes the standard of its preset itself. Over 1,615 +files of the first-party packages of Laravel run through Pint, `dresscode check --use laravel` finds 129 +places, each a deliberate difference the manual lists (October 2026). Rules a `pint.json` adds to the preset +`dresscode migrate config` translates as those of PHP CS Fixer. On top of it, the same run upgrades the +code to a newer PHP and, with `dresscode/upgrade-laravel`, to a newer Laravel, and it imports the classes +named in every tag of a doc comment, `@template` and `@method` included. + +  + Made for AI coding agents: Claude Code and Codex ================================================ @@ -604,7 +655,10 @@ DressCode is made to run after every edit, so what counts most is the time one f to a half of what PHP CS Fixer needs, 118 ms for a small file against 323 ms. Over a whole project, doing the same work, it is faster too. Run with only the rules that match the PER Coding Style set of PHP CS Fixer, DressCode takes 64 to 90 percent of its time in one process, and 46 to 64 percent in parallel -processes, over Laravel, WordPress, Symfony and Nette. +processes, over Laravel, WordPress, Symfony and Nette. Symfony with its own configuration of PHP CS Fixer, +translated by `dresscode migrate config`, is checked in 56 percent of the time, 14.2 s against 25.3 s over its +7,704 files; PHP CS Fixer runs a hundred more rules there, on phpDoc and PHPUnit, that have no +counterpart in DressCode. The preset `perCs` does more than that set: it also breaks long signatures, conditions, chains and arrays over lines and fixes the casing of names, none of which the set does. Preset against preset, a run diff --git a/src/Analyses/PhpStan.php b/src/Analyses/PhpStan.php index fa841607cc..db35b47c07 100644 --- a/src/Analyses/PhpStan.php +++ b/src/Analyses/PhpStan.php @@ -10,7 +10,7 @@ use Composer\Autoload\ClassLoader; use Composer\InstalledVersions; use DressCode\Config\ComposerJson; -use DressCode\Engine\Helpers; +use DressCode\Engine\{Helpers, Profiler}; use DressCode\Violation; use Nette\Utils\FileSystem; use PhpParser\{Node, NodeFinder}; @@ -85,6 +85,7 @@ public function __construct( string $tempDir, /** the version of PHP the code is written for, which PHPStan parses it as where the running PHP is older */ private readonly ?string $phpVersion = null, + private readonly ?Profiler $profiler = null, /** * @var list the directories of the packages installed in a newer version than the oldest one the project * supports, whose declarations may say what that one does not @@ -262,6 +263,7 @@ public function resolveScopes(string $path, string $code, array $ast, callable $ $resolver = $container->getByType(NodeScopeResolver::class); $resolver->setAnalysedFiles([$path]); $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($path)); + $start = $this->profiler ? hrtime(as_number: true) : 0; $inCallback = false; // a warning of PHPStan is the first sign of its failure on the file, and printed it would spoil the output; one it // silences is not, a rename into its cache another process has just written being one @@ -285,6 +287,8 @@ public function resolveScopes(string $path, string $code, array $ast, callable $ } finally { restore_error_handler(); } + + $this->profiler?->addPhase('phpstan: scopes', hrtime(as_number: true) - $start); } @@ -562,6 +566,7 @@ private function getContainer(): Container if (ini_get('opcache.file_cache_only')) { ini_set('opcache.enable', '0'); } + $start = $this->profiler ? hrtime(as_number: true) : 0; $container = $failure = null; foreach (array_unique([$config, null]) as $file) { try { @@ -582,6 +587,10 @@ private function getContainer(): Container . ' of the project: ' . strtok($failure->getMessage(), "\n") . '; the types go without it.'; } + $this->profiler?->addPhase( + $this->replacement === null ? 'phpstan: container' : 'phpstan: derived container build', + hrtime(as_number: true) - $start, + ); $this->configurationFiles ??= $this->collectConfigurationFiles($container); return $this->container = $container; } diff --git a/src/Config.php b/src/Config.php index 470a8844d6..e3dde65c18 100644 --- a/src/Config.php +++ b/src/Config.php @@ -86,7 +86,10 @@ public function __construct( /** @var list the extensions of the files to check, without a dot */ public array $fileExtensions = ['php'], ?callable $skipWhen = null, - /** .neon or .php file of violations left unreported, relative to the root; `dresscode baseline` writes it */ + /** + * .neon or .php file of violations left unreported, relative to the root, which must exist; `dresscode baseline` + * writes it, and without it `dresscode-baseline.*` of the root where there is one + */ public ?string $baseline = null, public ?string $cacheDir = null, array $analyses = [], diff --git a/src/Config/Catalogue.php b/src/Config/Catalogue.php index c55d73c18e..9f5f8289ba 100644 --- a/src/Config/Catalogue.php +++ b/src/Config/Catalogue.php @@ -56,7 +56,7 @@ final class Catalogue 'canonical' => null, 'alphabetical' => null, 'short' => null, 'long' => null, 'separated' => null, 'combined' => null, 'grouped' => null, 'joined' => null, 'PascalCase' => null, 'camelCase' => null, 'snake_case' => null, 'UPPER_CASE' => null, 'Pascal_Snake_Case' => null, - 'constant' => null, 'property' => null, 'traitUse' => null, + 'constant' => null, 'property' => null, 'traitUse' => null, 'reported' => 'reported where it stands', ]; /** diff --git a/src/Config/ComposerJson.php b/src/Config/ComposerJson.php index 473de750b9..c91afabcc5 100644 --- a/src/Config/ComposerJson.php +++ b/src/Config/ComposerJson.php @@ -8,11 +8,13 @@ namespace DressCode\Config; use DressCode\Engine\Helpers; -use function is_array, is_string; +use Nette\Utils\FileSystem; +use function is_array, is_string, strlen; /** - * What the files of Composer say about a project: where its composer.json is and the PHP it is written for. + * What the files of Composer say about a project: where its composer.json is, the PHP it is written for and where + * its code lies. * @internal */ final class ComposerJson @@ -57,9 +59,80 @@ public static function detectPhpTarget(?string $composerFile): ?string $constraint = is_string($constraint) && Versions::isConstraint($constraint) ? $constraint : null; $platform = $data['config']['platform']['php'] ?? null; if (is_string($platform) && Versions::isVersion($platform)) { - return $constraint === null ? ">=$platform" : Versions::raiseLowerBound($constraint, $platform); + return $constraint === null ? ">=$platform" : Versions::restrictFrom($constraint, $platform); } return $constraint !== null && Versions::findLowestVersion($constraint) !== null ? $constraint : null; } + + + /** + * The directories autoload and autoload-dev name, in the order of the file, relative to the root and with + * their dot segments resolved: the only place where a project itself says where its code is. A `files` + * entry is a single file, not a scope, and a classmap may name one too, so only what is a directory + * counts; a directory outside the root belongs to another project, since the file may be the one of a + * directory above. + * @return list + */ + public static function detectAutoloadPaths(?string $composerFile, string $root): array + { + $data = self::read($composerFile); + if ($data === null) { + return []; + } + + $base = Helpers::canonicalizePath(dirname((string) $composerFile)); + $root = Helpers::canonicalizePath($root); + $paths = []; + foreach (['autoload', 'autoload-dev'] as $section) { + foreach (['psr-4', 'psr-0', 'classmap'] as $kind) { + foreach ((array) ($data[$section][$kind] ?? []) as $value) { + foreach ((array) $value as $path) { // a psr-4 prefix takes one path or several + // `./src` and `src` are the same path, and only resolved do they compare as one + $directory = is_string($path) ? Helpers::canonicalizePath(FileSystem::normalizePath("$base/$path")) : null; + if ($directory === null || !is_dir($directory)) { + continue; + } elseif ($directory === $root) { + $paths['.'] = true; + } elseif (str_starts_with($directory, "$root/")) { + $paths[substr($directory, strlen($root) + 1)] = true; + } + } + } + } + } + + return array_keys($paths); + } + + + /** + * The files autoload and autoload-dev name one by one, of the kinds given, relative to the root, + * those outside it left out as the directories are, and those that do not exist kept, which the autoload fails on. + * @param list<'files'|'classmap'> $kinds + * @return list + */ + public static function detectAutoloadFiles(?string $composerFile, string $root, array $kinds = ['files', 'classmap']): array + { + $data = self::read($composerFile); + if ($data === null) { + return []; + } + + $base = Helpers::canonicalizePath(dirname((string) $composerFile)); + $root = Helpers::canonicalizePath($root); + $files = []; + foreach (['autoload', 'autoload-dev'] as $section) { + foreach ($kinds as $kind) { + foreach ((array) ($data[$section][$kind] ?? []) as $path) { + $file = is_string($path) ? Helpers::canonicalizePath(FileSystem::normalizePath("$base/$path")) : null; + if ($file !== null && !is_dir($file) && str_starts_with($file, "$root/")) { + $files[substr($file, strlen($root) + 1)] = true; + } + } + } + } + + return array_keys($files); + } } diff --git a/src/Config/ConfigResolver.php b/src/Config/ConfigResolver.php index 3c9ef51ba6..cff0e2e491 100644 --- a/src/Config/ConfigResolver.php +++ b/src/Config/ConfigResolver.php @@ -51,6 +51,11 @@ public function __construct( private readonly string $root = '.', /** @var list the packages whose plugins the project does not let in, so the maps of their rules are unknown */ private readonly array $pluginsSkipped = [], + /** + * @var list presets laid over those the configuration uses and beneath its own decisions and its overrides, + * so that the project keeps the last word, as `upgrade today` lays its sets + */ + private readonly array $laid = [], ) { $this->rulesOfOverrides = new \WeakMap; } @@ -106,7 +111,7 @@ public function resolve( self::addSymbols($symbols, $profile->namespaces, $layer->describe()); if ($profile->decisions !== []) { - new DecisionResolver($this->getCatalogue())->checkLayer($profile->decisions); + new DecisionResolver($this->getCatalogue(), translator: $this->registry->translator)->checkLayer($profile->decisions); $decisionLayers[] = [$layer, $profile->decisions]; } @@ -166,6 +171,7 @@ public function resolve( $this->project, typesAnalyzed: $typeAnalysis !== null && $this->typesAvailable, certainNames: $resolution === 'certain', + translator: $this->registry->translator, ); $decisions = $resolver->resolve($decisionLayers); if ($packageLayers = $this->collectPackageMaps($decisions, $narrowed)) { @@ -228,7 +234,7 @@ public function resolve( lineLength: self::resolveLineLength($values->get('file.lineLength')), tabWidth: $values->get('indentation.tabWidth')->getCountRange()[0], typeAnalysis: $this->typesAvailable ? $typeAnalysis : null, - packageTargets: array_diff_key($config->targets, ['php' => true]), + packageTargets: array_diff_key(RunnerFactory::mergeTargets($config, $commandLine), ['php' => true]), plugins: array_map( fn(string|Plugin $plugin) => is_string($plugin) ? $plugin : $plugin::class, [...$config->plugins, ...$commandLine instanceof Config ? $commandLine->plugins : []], @@ -246,7 +252,7 @@ public function resolve( /** - * The decisions each of the three standards makes, resolved alone. + * The decisions each of the standards makes, resolved alone. * @return array> standard => path => its decision * @throws ConfigurationException */ @@ -292,7 +298,7 @@ private function collectPackageMaps(array $decisions, ?array $narrowed): array $layers = []; foreach ($this->upgradingData as $package) { $requirement = $package->layer->decision; - $layer = $package->buildDecisions(function (string $map) use ($package, $requirement, $narrowed, $named, $covered): ?string { + $built = $package->buildDecisions(function (string $map) use ($package, $requirement, $narrowed, $named, $covered): ?string { $path = $this->findDataPath($map); if ($path === null && !in_array($package->layer->package, $this->pluginsSkipped, true)) { // the plugin skipped says so $this->warnings[$package->layer->describe() . " $map"] = "Map `$map`, which " @@ -320,8 +326,8 @@ private function collectPackageMaps(array $decisions, ?array $narrowed): array $laid = $requirement === null ? ($decisions[self::PackagesDecision] ?? null)?->value->getFlag() : ($decisions[$requirement] ?? null)?->value->isKept() === false; - if ($layer !== [] && $laid) { - $layers[] = [$package->layer, $layer]; + if ($laid) { + array_push($layers, ...$built); } } @@ -639,6 +645,10 @@ private function collectLayers(array $profiles): array $layers = $visited = $repeated = []; foreach ($profiles as [$layer, $profile]) { $this->collectUsed($profile, $layer, $layers, $visited, $repeated); + if ($layer->kind === LayerKind::Configuration && $this->laid !== []) { + $this->collectUsed(new Profile(use: $this->laid), new Layer(LayerKind::Caller), $layers, $visited, $repeated); + } + $layers[] = [$layer, $profile]; } @@ -704,9 +714,12 @@ private function collectUsed(Profile $profile, Layer $source, array &$layers, ar private function warnRepeated(array $repeated): void { foreach ($repeated as [$layer, $laidBy, $namedBy]) { - // what a preset uses is not the project's to change, and the command line reaches what an override does not + // what a preset or the caller lays is not the project's to change, and the command line reaches what an override + // does not if ( $namedBy->kind !== LayerKind::Preset + && $namedBy->kind !== LayerKind::Caller + && $laidBy->kind !== LayerKind::Caller && !($laidBy->kind === LayerKind::Override && $namedBy->kind === LayerKind::CommandLine) ) { $this->warnings['repeated ' . $layer->describe() . ' ' . $namedBy->describe()] = ucfirst($namedBy->formatReference()) @@ -745,7 +758,7 @@ private function resolveOnly(array $names): array * section named like a preset is written `section.*`, the bare name being refused as both. * @throws ConfigurationException */ - private function expandName(string $name): ExpandedName + public function expandName(string $name): ExpandedName { $package = $this->findDataPackage($name); if ($package !== null) { @@ -754,6 +767,10 @@ private function expandName(string $name): ExpandedName if ($data->layer->package === $package || $data->package === $package) { $keys = [...array_keys($data->maps), ...array_keys($data->requirements)]; array_push($paths, ...array_filter(array_map($this->findDataPath(...), $keys))); + // the data of PHP are reported under the requirement laying them + if ($data->layer->decision !== null) { + $paths[] = $data->layer->decision; + } } } diff --git a/src/Config/CorePlugin.php b/src/Config/CorePlugin.php index 84dab1837c..91ebe7066c 100644 --- a/src/Config/CorePlugin.php +++ b/src/Config/CorePlugin.php @@ -24,9 +24,10 @@ final class CorePlugin implements Plugin { /** - * The presets that are complete standards; the other presets are sets. + * The presets that are complete standards, the first of them the one taken where the code fits several equally; the + * other presets are sets. */ - public const Standards = ['perCs', 'psr12', 'nette']; + public const Standards = ['perCs', 'psr12', 'nette', 'symfony', 'laravel']; private const QualifiedName = '\\\\?\w+(\\\\\w+)*'; @@ -39,6 +40,8 @@ public function getManifest(): PluginManifest 'dresscode/perCs' => dirname(__DIR__) . '/Presets/perCs.neon', 'dresscode/psr12' => dirname(__DIR__) . '/Presets/psr12.neon', 'dresscode/nette' => dirname(__DIR__) . '/Presets/nette.neon', + 'dresscode/symfony' => dirname(__DIR__) . '/Presets/symfony.neon', + 'dresscode/laravel' => dirname(__DIR__) . '/Presets/laravel.neon', 'dresscode/cleanup' => dirname(__DIR__) . '/Presets/cleanup.neon', 'dresscode/compilerOptimizations' => dirname(__DIR__) . '/Presets/compilerOptimizations.neon', 'dresscode/correctness' => dirname(__DIR__) . '/Presets/correctness.neon', @@ -180,7 +183,7 @@ public function getManifest(): PluginManifest Rules\Functions\NoConversionFunctionsRule::class, Rules\Functions\NoDebugOutputRule::class, Rules\Functions\NoDirnameOfFileRule::class, - Rules\Functions\NoExplicitInvokeCallsRule::class, + Rules\Functions\InvokeCallNotationRule::class, Rules\Functions\NoInnerFunctionsRule::class, Rules\Functions\NoIsNullRule::class, Rules\Functions\NoManualSubstringTestsRule::class, @@ -255,6 +258,8 @@ public function getManifest(): PluginManifest Rules\Upgrading\NoDeprecatedMembersRule::class, Rules\Upgrading\NoDeprecatedFunctionsRule::class, Rules\Upgrading\NoDeprecatedPhpArgumentsRule::class, + // what the rewrites leave of a string naming a class, and what none of them could see + Rules\Upgrading\UnseenReferencesRule::class, Rules\Variables\NoGlobalStatementsRule::class, Rules\Variables\NoRepeatedAssignmentsRule::class, Rules\Variables\NoSeparateIssetsRule::class, diff --git a/src/Config/DataPackageCatalogue.php b/src/Config/DataPackageCatalogue.php new file mode 100644 index 0000000000..1aeaabdd89 --- /dev/null +++ b/src/Config/DataPackageCatalogue.php @@ -0,0 +1,89 @@ +> package => the libraries it is about, a whole vendor written `vendor/`; + * empty where Packagist cannot be reached and nothing is remembered + */ + public static function load(string $cacheDir, bool $offline = false): array + { + $file = "$cacheDir/data-packages.json"; + $cached = is_file($file) ? json_decode((string) file_get_contents($file), associative: true) : null; + $cached = is_array($cached) ? $cached : null; + if ($offline || ($cached !== null && filemtime($file) > time() - self::MaxAge)) { + return $cached ?? []; + } + + $fetched = self::fetch(); + try { + FileSystem::write($file, json_encode($fetched ?? $cached ?? [], JSON_THROW_ON_ERROR)); + if ($fetched === null) { + touch($file, time() - self::MaxAge + self::FailureAge); + } + } catch (\Throwable) { // a cache that cannot be written only means asking again + } + + return $fetched ?? $cached ?? []; + } + + + /** @return ?array> null where Packagist does not answer */ + private static function fetch(): ?array + { + $names = self::request(self::ListUrl)['packageNames'] ?? null; + if (!is_array($names)) { + return null; + } + + $res = []; + foreach (array_filter($names, is_string(...)) as $name) { + // the newest release, or the development version of a package with none + $metadata = self::request(sprintf(self::MetadataUrl, $name))['packages'][$name][0] + ?? self::request(sprintf(self::MetadataUrl, "$name~dev"))['packages'][$name][0] + ?? null; + $libraries = $metadata['extra']['dresscode']['packages'] ?? null; + if (is_array($libraries) && array_all($libraries, fn($library) => is_string($library))) { + $res[$name] = array_values($libraries); + } + } + + return $res; + } + + + /** @return ?array */ + private static function request(string $url): ?array + { + $context = stream_context_create(['http' => ['timeout' => 5, 'user_agent' => 'DressCode']]); + $json = @file_get_contents($url, context: $context); // @ a failure is no answer + $data = $json === false ? null : json_decode($json, associative: true); + return is_array($data) ? $data : null; + } +} diff --git a/src/Config/DecisionResolver.php b/src/Config/DecisionResolver.php index 609271a10c..c28f1afcfd 100644 --- a/src/Config/DecisionResolver.php +++ b/src/Config/DecisionResolver.php @@ -9,6 +9,7 @@ use DressCode\{Config, ConfigurationException, Decision, DecisionKind, Domain, Rule, RuleInfo, Value, Values}; use DressCode\Domains\{Count, Flag, Map, Words}; +use DressCode\Interop\Translator; use Nette\Utils\Helpers; use function count, is_array, is_bool, is_int, is_string, strlen; @@ -36,6 +37,8 @@ public function __construct( private bool $typesAnalyzed = false, /** whether the namespaces of the configuration are complete, which turns on their guard */ private bool $certainNames = false, + /** knows the names of the rules of other tools and the decisions that cover them */ + private Translator $translator = new Translator, ) { } @@ -297,7 +300,14 @@ private function refuseKey(string $path, string $prefix): never } } + $covered = $prefix === '' ? $this->translator->findPaths($path) : []; $hint = Helpers::getSuggestion(array_keys($known), $path); - throw new ConfigurationException("Key `$path` is unknown" . ($hint !== null ? "; write `$hint`." : '.')); + throw new ConfigurationException("Key `$path` is unknown" . match (true) { + $covered !== [] => '. It is the name of a rule of another tool, covered by `' + . implode('` and `', $covered) + . '`; `dresscode migrate config` translates a configuration of another tool.', + $hint !== null => "; write `$hint`.", + default => '.', + }, docs: $covered !== [] ? 'migration#migrate-config' : null); } } diff --git a/src/Config/Draft.php b/src/Config/Draft.php new file mode 100644 index 0000000000..bcb03b449c --- /dev/null +++ b/src/Config/Draft.php @@ -0,0 +1,491 @@ + */ + private array $use = []; + + private string $useComment = ''; + + /** @var ?array{list, array, list} paths, path left out => why, extensions */ + private ?array $scope = null; + + /** @var ?array{list, list, string} functions, constants, the comment */ + private ?array $namespaces = null; + + private ?string $typeAnalysis = null; + + /** @var array path => value as the configuration writes it */ + private array $decisions = []; + + /** @var array path => what the value rests on */ + private array $comments = []; + + /** @var list the override, what it rests on */ + private array $overrides = []; + + + public function __construct( + /** the comment the file opens with */ + private readonly string $header = '', + ) { + } + + + /** @param list $presets */ + public function setUse(array $presets, string $comment = ''): static + { + $this->use = $presets; + $this->useComment = $comment; + return $this; + } + + + /** + * @param list $paths + * @param array $excludePaths path => why it is left out + * @param list $fileExtensions + */ + public function setScope(array $paths, array $excludePaths = [], array $fileExtensions = ['php']): static + { + $this->scope = [$paths, $excludePaths, $fileExtensions]; + return $this; + } + + + /** + * The functions and constants the namespaces declare and no others, so that a name is resolved for certain. + * @param list $functions + * @param list $constants + */ + public function setNamespaces(array $functions, array $constants, string $comment = ''): static + { + $this->namespaces = [$functions, $constants, $comment]; + return $this; + } + + + /** The rules get the types of the code from PHPStan. */ + public function setTypeAnalysis(string $comment = ''): static + { + $this->typeAnalysis = $comment; + return $this; + } + + + public function setDecision(string $path, mixed $value, string $comment = ''): static + { + $this->decisions[$path] = $value; + $this->comments[$path] = $comment; + return $this; + } + + + public function addOverride(Override $override, string $comment = ''): static + { + $this->overrides[] = [$override, $comment]; + return $this; + } + + + /** + * The configuration written in the format given, `neon` or `php`, resolved the way a run reads it before it is + * anywhere a run could read it and checked to say what the draft does. + * @throws \LogicException where it says something else + * @throws ConfigurationException + */ + public function renderChecked(string $root, string $format = 'neon'): string + { + $text = $format === 'php' ? $this->toPhp() : $this->toNeon(); + $temp = sys_get_temp_dir() . '/dresscode-draft-' . uniqid(more_entropy: true) . ".$format"; + FileSystem::write($temp, $text); + try { + $config = Loader::loadFile($temp); + $this->checkLoaded($config); + $this->checkResolution((new RunnerFactory)->resolve($config, $root)->resolvedConfig); + } finally { + @unlink($temp); // @ file may have been deleted meanwhile + } + + return $text; + } + + + /** + * What the configuration loads as must be what the draft says of the presets, the scope and the paths of the + * overrides, which a run reads before it resolves anything. + * @throws \LogicException + */ + private function checkLoaded(Config $config): void + { + [$paths, $excluded, $extensions] = $this->scope ?? [[], [], ['php']]; + $use = [...$config->use, ...array_map(fn($plugin) => is_string($plugin) ? $plugin : $plugin::class, $config->plugins)]; + $expected = $this->use; + sort($use); + sort($expected); + $problem = match (true) { + $use !== $expected => '`use` names other presets', + $config->paths !== $paths => '`paths` are other paths', + $config->excludePaths !== array_values(array_unique([...Config::DefaultExcludePaths, ...array_keys($excluded)])) + => '`excludePaths` are other paths', + $config->fileExtensions !== $extensions => '`fileExtensions` are other extensions', + array_map(fn(Override $override) => $override->paths, $config->overrides) + !== array_map(fn(array $entry) => $entry[0]->paths, $this->overrides) => 'an override is for other paths', + default => null, + }; + if ($problem !== null) { + throw new \LogicException("The configuration written does not say what it was made to: $problem."); + } + } + + + /** + * What a run makes of the configuration must be what the draft says: its decisions, the names it resolves for + * certain and the types; anything else is a configuration that says one thing and does another. + * @throws \LogicException + */ + public function checkResolution(ResolvedConfig $resolved): void + { + $catalogue = self::getCatalogue(); + $problem = null; + foreach ($this->decisions as $path => $value) { + $decision = $catalogue->find($path); + if ($decision === null) { // a decision of a plugin, which the core does not know how to accept + continue; + } + + $expected = $decision->accept($value); + $actual = ($resolved->decisions[$path] ?? null)?->value; + if ($actual === null || $actual->isKept() !== $expected->isKept() || $actual->toData() !== $expected->toData()) { + $problem = "`$path` resolves to another value"; + break; + } + } + + [$functions, $constants] = $this->namespaces ?? [[], []]; + $problem ??= match (true) { + $this->namespaces !== null && $resolved->nameResolution !== 'certain' => 'the names are not resolved for certain', + array_diff($functions, array_keys($resolved->namespacedFunctions)) !== [] => 'a function the namespaces declare is not listed', + array_diff($constants, array_keys($resolved->namespacedConstants)) !== [] => 'a constant the namespaces declare is not listed', + // a DressCode without PHPStan beside it goes without the types the project can give + ($this->typeAnalysis !== null) !== ($resolved->typeAnalysis === 'phpstan') && PhpStan::isAvailable() + => 'the types are not what it asks for', + default => null, + }; + if ($problem !== null) { + throw new \LogicException("The configuration written does not say what it was made to: $problem."); + } + } + + + /** + * The configuration as the data a check reads. + * @return array + */ + public function toArray(): array + { + $config = $this->buildSettings() + $this->buildTree(); + foreach ($this->overrides as [$override]) { + $config['overrides'][] = ['paths' => $override->paths, ...$override->profile->decisions]; + } + + return $config; + } + + + /** The configuration as `dresscode.neon`, each key with its comment, the sections apart. */ + public function toNeon(): string + { + $sections = [ + ($this->header === '' ? '' : preg_replace('~^~m', '# ', $this->header) . "\n") . $this->renderUse(), + $this->renderScope(), + $this->renderNamespaces(), + $this->typeAnalysis === null ? '' : 'typeAnalysis: phpstan' . self::formatComment($this->typeAnalysis) . "\n", + $this->renderDecisions(), + $this->renderOverrides(), + ]; + $sections = array_filter($sections, fn(string $section) => $section !== ''); + return $sections ? implode("\n", $sections) : "use: []\n"; + } + + + /** The configuration as the source of `dresscode.php`. */ + public function toPhp(): string + { + $arguments = ''; + foreach ($this->buildSettings() as $key => $value) { + $arguments .= "\t$key: " . self::export($value, 1) . ",\n"; + } + + if ($decisions = $this->buildTree()) { + $arguments .= "\tdecisions: " . self::export($decisions, 1) . ",\n"; + } + + if ($this->overrides) { + $arguments .= "\toverrides: [\n"; + foreach ($this->overrides as [$override]) { + $arguments .= "\t\tnew Override(" . self::export($override->paths) + . ', new Profile(decisions: ' . self::export($override->profile->decisions, 2) . ")),\n"; + } + + $arguments .= "\t],\n"; + } + + return "overrides ? '{Config, Override, Profile}' : 'Config') . ";\n\n" + . ($arguments === '' ? "return new Config;\n" : "return new Config(\n$arguments);\n"); + } + + + /** + * The keys of the configuration beside the decisions and the overrides, those with their default value left out. + * @return array + */ + private function buildSettings(): array + { + [$paths, $excluded, $extensions] = $this->scope ?? [[], [], ['php']]; + return array_filter([ + 'use' => $this->use, + 'paths' => $paths, + 'excludePaths' => array_keys($excluded), + 'fileExtensions' => $extensions === ['php'] ? [] : $extensions, + 'nameResolution' => $this->namespaces === null ? null : 'certain', + 'namespaces' => $this->namespaces === null ? [] : ['functions' => $this->namespaces[0], 'constants' => $this->namespaces[1]], + 'typeAnalysis' => $this->typeAnalysis === null ? null : 'phpstan', + ], fn($value) => $value !== null && $value !== []); + } + + + /** + * The decisions as the tree of sections, in the order of the sections of the core. + * @return array + */ + private function buildTree(): array + { + $tree = []; + foreach ($this->sortDecisions() as $path => $value) { + $tree = Helpers::placeValue($tree, $path, $value); + } + + return $tree; + } + + + /** + * The decisions in the order of the catalogue, a section by the order of the core and every group below it where + * its first decision stands, so that a group is written together. + * @return array + */ + private function sortDecisions(): array + { + $rank = []; + foreach (array_keys(self::getCatalogue()->getDecisions()) as $index => $path) { + for ($prefix = $path; $prefix !== ''; $prefix = substr($prefix, 0, (int) strrpos($prefix, '.'))) { + $rank[$prefix] ??= $index; + } + } + + $sections = array_flip(Catalogue::CoreSections); + $key = function (string $path) use ($rank, $sections): string { + $links = explode('.', $path); + $key = sprintf('%06d', $sections[$links[0]] ?? count($sections)); + for ($i = 2; $i <= count($links); $i++) { + $key .= sprintf('.%06d', $rank[implode('.', array_slice($links, 0, $i))] ?? 999999); + } + + return "$key $path"; + }; + $decisions = $this->decisions; + uksort($decisions, fn(string $a, string $b) => $key($a) <=> $key($b)); + return $decisions; + } + + + private static function getCatalogue(): Catalogue + { + static $catalogue; + return $catalogue ??= new ConfigResolver(new PluginRegistry)->getCatalogue(); + } + + + /** The key `use`, on one line for a single entry, the comment following the standard. */ + private function renderUse(): string + { + $comment = self::formatComment($this->useComment); + return match (count($this->use)) { + 0 => '', + 1 => 'use: ' . Neon::encode($this->use[0]) . "$comment\n", + default => "use:\n" . implode('', array_map( + fn(string $entry, int $i) => "\t- " . Neon::encode($entry) . ($i === 0 ? $comment : '') . "\n", + $this->use, + array_keys($this->use), + )), + }; + } + + + /** The paths, those left out each with the reason beside it, and the extensions where they are not only `php`. */ + private function renderScope(): string + { + if ($this->scope === null) { + return ''; + } + + [$paths, $excluded, $extensions] = $this->scope; + $out = 'paths: ' . self::renderList($paths) . "\n"; + if ($excluded) { + $written = array_map(fn(string $path) => Neon::encode($path), array_keys($excluded)); + $width = max(array_map(strlen(...), $written)); + $out .= "excludePaths:\n" . implode('', array_map( + fn(string $path, string $why) => "\t- " . str_pad($path, $width) . " # $why\n", + $written, + $excluded, + )); + } + + return $out . ($extensions === ['php'] ? '' : 'fileExtensions: ' . self::renderList($extensions) . "\n"); + } + + + private function renderNamespaces(): string + { + if ($this->namespaces === null) { + return ''; + } + + [$functions, $constants, $comment] = $this->namespaces; + $lists = ''; + foreach (['functions' => $functions, 'constants' => $constants] as $key => $names) { + $lists .= $names ? "\t$key:\n" . implode('', array_map(fn(string $name) => "\t\t- " . Neon::encode($name) . "\n", $names)) : ''; + } + + return ($comment === '' ? '' : preg_replace('~^~m', '# ', $comment) . "\n") + . "nameResolution: certain\n" + . ($lists === '' ? '' : "namespaces:\n$lists"); + } + + + /** The decisions under their sections, each with its comment, the comments aligned. */ + private function renderDecisions(): string + { + if (!$this->decisions) { + return ''; + } + + $catalogue = self::getCatalogue(); + $lines = []; + $previous = []; + foreach ($this->sortDecisions() as $path => $value) { + $links = explode('.', $path); + $key = array_pop($links); + $common = 0; + while (isset($previous[$common], $links[$common]) && $previous[$common] === $links[$common]) { + $common++; + } + + if ($common === 0 && $lines) { + $lines[] = ['', '']; + } + + for ($depth = $common; $depth < count($links); $depth++) { + $lines[] = [str_repeat("\t", $depth) . "$links[$depth]:", '']; + } + + $written = $catalogue->find($path)?->accept($value)->toWrittenText() ?? Neon::encode($value); + $lines[] = [str_repeat("\t", count($links)) . "$key: $written", $this->comments[$path]]; + $previous = $links; + } + + $width = fn(string $line) => strlen(str_replace("\t", ' ', $line)); + $column = max(0, ...array_map(fn(array $line) => $width($line[0]), $lines)) + 3; + return implode('', array_map( + fn(array $line) => $line[1] === '' + ? "$line[0]\n" + : $line[0] . str_repeat(' ', $column - $width($line[0])) . "# $line[1]\n", + $lines, + )); + } + + + /** The overrides, each with what it rests on above it. */ + private function renderOverrides(): string + { + if (!$this->overrides) { + return ''; + } + + $out = "overrides:\n"; + foreach ($this->overrides as [$override, $comment]) { + // an entry is written without the blank lines NEON puts between the keys of a block + $entry = rtrim( + (string) preg_replace( + '~\n+~', + "\n", + Neon::encode(['paths' => $override->paths, ...$override->profile->decisions], blockMode: true), + ), + ); + $out .= ($comment === '' ? '' : "\t# $comment\n") . "\t-\n" . preg_replace('~^~m', "\t\t", $entry) . "\n"; + } + + return $out; + } + + + /** @param list $items */ + private static function renderList(array $items): string + { + $items = array_map(fn(string $item) => Neon::encode($item), $items); + $inline = '[' . implode(', ', $items) . ']'; + return strlen($inline) <= self::InlineWidth ? $inline : "\n" . implode('', array_map(fn(string $item) => "\t- $item\n", $items)); + } + + + private static function formatComment(string $comment): string + { + return $comment === '' ? '' : " # $comment"; + } + + + private static function export(mixed $value, int $level = 0): string + { + if ($value === null) { + return 'null'; + } elseif (!is_array($value)) { + return var_export($value, return: true); + } elseif (array_is_list($value) && !array_any($value, fn($item) => is_array($item))) { + return '[' . implode(', ', array_map(fn($item) => self::export($item), $value)) . ']'; + } + + $indent = str_repeat("\t", $level + 1); + $list = array_is_list($value); + $items = ''; + foreach ($value as $key => $item) { + $items .= $indent . ($list ? '' : var_export($key, return: true) . ' => ') . self::export($item, $level + 1) . ",\n"; + } + + return "[\n$items" . str_repeat("\t", $level) . ']'; + } +} diff --git a/src/Config/Layer.php b/src/Config/Layer.php index 21fe7de325..9fe172d5f6 100644 --- a/src/Config/Layer.php +++ b/src/Config/Layer.php @@ -21,12 +21,34 @@ public function __construct( public ?string $package = null, /** the requirement that lays the upgrading file and what it says is reported under, null for the flag `upgrading.packages` */ public ?string $decision = null, + /** the package whose versions the section of an upgrading file is of, `php` for PHP itself */ + public ?string $library = null, + /** the version of the section of an upgrading file */ + public ?string $since = null, + /** the version the fixes of the entries of the section are right from, where it is older than the section */ + public ?string $from = null, ) { } - /** The layer as a value is said by it: `the configuration`, `dresscode/nette`, `upgrading.neon of acme/mailer`. */ + /** The layer of one section of the upgrading file this layer is, with the version its entries are right from. */ + public function withSection(string $library, string $since, ?string $from): self + { + return new self($this->kind, $this->name, $this->package, $this->decision, $library, $since, $from); + } + + + /** + * The layer as a value is said by it: `the configuration`, `dresscode/nette`, `upgrading.neon of acme/mailer`, + * `application.neon of dresscode/upgrade-nette, since 3.2`. + */ public function describe(): string + { + return $this->describeSource() . ($this->since === null ? '' : ", since $this->since"); + } + + + private function describeSource(): string { return match ($this->kind) { LayerKind::Configuration => 'the configuration', @@ -35,7 +57,8 @@ public function describe(): string LayerKind::Override => "the override for $this->name", LayerKind::Preset => (string) $this->name, LayerKind::Package => match (true) { - $this->package === null => (string) $this->name, + $this->name === null => "DressCode for $this->package", + $this->package === null => $this->name, default => "$this->name of $this->package", }, }; diff --git a/src/Config/Loader.php b/src/Config/Loader.php index 5c08dc382b..4c10435275 100644 --- a/src/Config/Loader.php +++ b/src/Config/Loader.php @@ -28,24 +28,27 @@ final class Loader /** * The root is the directory of the configuration file, and without one that of the nearest `composer.json`, else - * the directory itself. + * the directory itself; a root given wins over all of them. * @param ?string $file the configuration file, or null to search from the directory upwards * @param ?Config $default what applies when there is no configuration file * @return array{Config, string, ?string} the configuration, the root directory with slashes, and the file it came from * @throws ConfigurationException */ - public static function load(?string $file, string $directory, ?Config $default = null): array + public static function load(?string $file, string $directory, ?Config $default = null, ?string $root = null): array { $file ??= self::find($directory); if ($file === null) { $config = $default ?? throw new ConfigurationException( - "No `dresscode.neon` or `dresscode.php` found in `$directory` or above it, so there is no dress code to check against. Name a standard with `--use`.", + "No `dresscode.neon` or `dresscode.php` found in `$directory` or above it, so there is no dress code to check against. Run `dresscode init` to have one made to measure from the code, or name a standard with `--use`.", + docs: 'cli#init', ); - $composer = ComposerJson::findFile($directory); - $root = $composer === null ? $directory : dirname($composer); + if ($root === null) { + $composer = ComposerJson::findFile($directory); + $root = $composer === null ? $directory : dirname($composer); + } } else { $config = self::loadFile($file); - $root = dirname($file); + $root ??= dirname($file); } $root = realpath($root) ?: $root; @@ -54,23 +57,37 @@ public static function load(?string $file, string $directory, ?Config $default = /** - * The nearest configuration file in the directory or above it: within a directory the local file wins - * over the .dist template, and the two formats are an ambiguity nobody can resolve for the user. + * The nearest configuration file in the directory or above it. * @throws ConfigurationException */ public static function find(string $directory): ?string { foreach (Helpers::walkUp($directory) as $directory) { - foreach (['', self::DistSuffix] as $suffix) { - $found = array_values(array_filter( - array_map(fn(string $name) => "$directory/$name$suffix", self::FileNames), - is_file(...), - )); - if (count($found) > 1) { - throw new ConfigurationException('Both `' . implode('` and `', $found) . '` exist; keep one of them.'); - } elseif ($found) { - return $found[0]; - } + if (($file = self::findIn($directory)) !== null) { + return $file; + } + } + + return null; + } + + + /** + * The configuration file lying in the directory itself: the local file wins over the .dist template, and the two + * formats are an ambiguity nobody can resolve for the user. + * @throws ConfigurationException + */ + public static function findIn(string $directory): ?string + { + foreach (['', self::DistSuffix] as $suffix) { + $found = array_values(array_filter( + array_map(fn(string $name) => "$directory/$name$suffix", self::FileNames), + is_file(...), + )); + if (count($found) > 1) { + throw new ConfigurationException('Both `' . implode('` and `', $found) . '` exist; keep one of them.'); + } elseif ($found) { + return $found[0]; } } diff --git a/src/Config/PackageDiscovery.php b/src/Config/PackageDiscovery.php index 79ae52d2cb..a5a053469d 100644 --- a/src/Config/PackageDiscovery.php +++ b/src/Config/PackageDiscovery.php @@ -8,7 +8,7 @@ namespace DressCode\Config; use Composer\InstalledVersions; -use DressCode\{ConfigurationException, Plugin}; +use DressCode\{ConfigurationException, Plugin, Profile}; use DressCode\Engine\Helpers; use function array_key_exists, is_array, is_string; @@ -18,16 +18,20 @@ * the directory `extra.dresscode.upgrading-dir` names, read for the versions of their package the code must work with * (`UpgradingFile::selectFor()`), with those of PHP for its target (`readPhpData()`), and the plugin under * `extra.dresscode.plugin`, which the project lets in by naming the package or the plugin in `use`, as Composer lets in - * its own plugins. The root package takes part too, and a file about the root itself applies whole. Such a file never - * turns a rule on itself; its maps, and the requirements of the rules of the plugin of its package, are laid under those - * of the project where the project says `upgrading.packages: yes`, those of PHP where it says + * its own plugins. The root package takes part too, and a file about the root itself applies whole, and so do the + * packages of upgrading data installed beside DressCode, which `upgrade today` installs for a project lacking them. Such a + * file never turns a rule on itself; its maps, and the requirements of the rules of the plugin of its package, are + * laid under those of the project where the project says `upgrading.packages: yes`, those of PHP where it says * `upgrading.php.deprecatedApi: forbidden`. * @internal */ final readonly class PackageDiscovery { /** the keys of `extra.dresscode` a package may use */ - public const Keys = ['upgrading-dir', 'plugin']; + public const Keys = ['upgrading-dir', 'packages', 'plugin']; + + /** the type of the packages that bring DressCode upgrading data or a plugin */ + public const PackageType = 'dresscode-plugin'; /** the platform package of Composer the upgrading data of PHP are about, which DressCode ships itself */ public const Php = 'php'; @@ -38,6 +42,19 @@ /** the requirement that lays the upgrading data of PHP, under which what they say is reported */ public const PhpDecision = 'upgrading.php.deprecatedApi'; + /** + * What packages declare in their namespaces for the files a project writes into them, which a package does not say + * itself yet: the container configurator of Symfony, whose functions the files that configure the services call + * without importing them. + */ + private const Known = [ + 'symfony/dependency-injection' => [ + 'functions' => [ + 'Symfony\Component\DependencyInjection\Loader\Configurator\{abstract_arg, closure, env, expr, inline_service, iterator, lazy_proxy, param, service, service_closure, service_locator, tagged_class_map, tagged_iterator, tagged_locator}', + ], + ], + ]; + private function __construct( /** @var list the root package first */ @@ -50,10 +67,14 @@ private function __construct( } - /** @throws ConfigurationException for a file a package ships that cannot be read */ - public static function discover(ProjectPackages $project): self + /** + * @param ?array $installation the packages of upgrading data installed beside DressCode => where + * they lie, by default those of the running process + * @throws ConfigurationException for a file a package ships that cannot be read + */ + public static function discover(ProjectPackages $project, ?array $installation = null): self { - // the root package first, then the installed ones as Composer lists them + // the root package first, then the installed ones as Composer lists them, then the data beside DressCode $packages = $project->rootPath === null ? [] : [[$project->rootName ?? 'the root package', $project->rootPath, $project->rootExtra['dresscode'] ?? null]]; @@ -61,6 +82,12 @@ public static function discover(ProjectPackages $project): self $packages[] = [$name, $package['path'], $package['extra']['dresscode'] ?? null]; } + foreach ($installation ?? self::findInstalledDataPackages() as $name => $path) { + if (!$project->has($name)) { + $packages[] = [$name, $path, ComposerJson::read("$path/composer.json")['extra']['dresscode'] ?? null]; + } + } + $upgradingData = $plugins = $warnings = []; foreach ($packages as [$name, $path, $extra]) { if (!is_array($extra)) { @@ -95,6 +122,17 @@ public static function discover(ProjectPackages $project): self } } + foreach (self::Known as $package => $namespaces) { + if ($project->has($package)) { + $upgradingData[] = new UpgradingData( + new Layer(LayerKind::Package, package: $package), + $package, + new Profile(namespaces: $namespaces)->namespaces, + [], + ); + } + } + $php = self::readPhpData($project); return new self($php === null ? $upgradingData : [...$upgradingData, $php], $plugins, $warnings); } @@ -111,6 +149,65 @@ public static function readPhpData(ProjectPackages $project): ?UpgradingData } + /** + * The packages shipping upgrading files, with the data of each. + * @return array> + */ + public function getDataPackages(): array + { + $res = []; + foreach ($this->upgradingData as $data) { + if ($data->layer->name !== null && $data->layer->package !== null) { + $res[$data->layer->package][] = $data; + } + } + + return $res; + } + + + /** + * The plugins of the packages shipping upgrading files, which an upgrade lets in without `use` naming them. + * @return list> + */ + public function findDataPlugins(): array + { + return array_values(array_intersect_key($this->plugins, $this->getDataPackages())); + } + + + /** + * The packages shipping upgrading files whose data reach the project, with a map or a requirement for the versions + * the code must work with, which no set covers. + * @return list + */ + public function findReachingPackages(): array + { + return array_keys(array_filter( + $this->getDataPackages(), + fn(array $files) => array_any($files, fn(UpgradingData $data) => $data->maps !== [] || $data->requirements !== []), + )); + } + + + /** + * The newest version the upgrading data of each library know of, PHP among them. + * @return array library => version + */ + public function findKnownVersions(): array + { + $versions = []; + foreach ($this->upgradingData as $data) { + if ($data->newest !== null && version_compare($data->newest, $versions[$data->package] ?? '0', '>')) { + $versions[$data->package] = $data->newest; + } + } + + ksort($versions); + return $versions; + } + + /** * The upgrading files of a package, relative to where it lies: every NEON file of the directory * `extra.dresscode.upgrading-dir` names, in the order of their names. @@ -134,6 +231,61 @@ public static function findUpgradingFiles(string $path, array $extra, string $pa } + /** + * The packages of upgrading data about a library the project requires itself, which neither the project nor the + * installation of DressCode holds. + * @param array> $catalogue package => the libraries it says it is about (`DataPackageCatalogue`) + * @param ?array $installation as `discover()` takes it + * @return list + */ + public static function findMissingDataPackages(ProjectPackages $project, array $catalogue, ?array $installation = null): array + { + $installation ??= self::findInstalledDataPackages(); + $missing = []; + foreach ($catalogue as $package => $libraries) { + if ($project->has($package) || isset($installation[$package])) { + continue; + } + + if (array_any(array_keys($project->required), fn(string $name) => self::isAbout($libraries, $name))) { + $missing[] = $package; + } + } + + return $missing; + } + + + /** + * Whether the library is one of those `extra.dresscode.packages` lists, a whole vendor written `vendor/`. + * @param list $libraries + */ + public static function isAbout(array $libraries, string $library): bool + { + return array_any($libraries, fn(string $name) => str_ends_with($name, '/') ? str_starts_with($library, $name) : $library === $name); + } + + + /** + * The packages of upgrading data installed beside DressCode, as Composer says of the running process. + * @return array package => where it lies + */ + public static function findInstalledDataPackages(): array + { + $found = []; + $packages = class_exists(InstalledVersions::class) ? InstalledVersions::getInstalledPackagesByType(self::PackageType) : []; + foreach (array_unique($packages) as $package) { + $path = InstalledVersions::getInstallPath($package); + $extra = $path === null ? null : (ComposerJson::read("$path/composer.json")['extra']['dresscode'] ?? null); + if (isset($extra['upgrading-dir'])) { + $found[$package] = Helpers::canonicalizePath(realpath($path) ?: $path); + } + } + + return $found; + } + + /** * The directory of the `composer.json` DressCode is installed by, globally, beside a project or as the project; * null for a phar and for a process Composer did not load. diff --git a/src/Config/PluginRegistry.php b/src/Config/PluginRegistry.php index c2770891a1..50241e0ef5 100644 --- a/src/Config/PluginRegistry.php +++ b/src/Config/PluginRegistry.php @@ -8,13 +8,14 @@ namespace DressCode\Config; use DressCode\{ConfigurationException, Decision, DecisionKind, Plugin, QualifiedNames, Rule}; +use DressCode\Interop\Translator; use Nette\Utils\Helpers; use function array_key_exists, strlen; /** * What a run knows of its plugins: the rules by their classes, the presets by name, the pages of their documentation, - * the sections and the catalogue. A name of a preset without a vendor is the + * the sections, the translator and the catalogue. A name of a preset without a vendor is the * one of the core of that name, so `'perCs'` is `'dresscode/perCs'`. * @internal */ @@ -61,8 +62,9 @@ final class PluginRegistry private ?Catalogue $catalogue = null; - public function __construct() - { + public function __construct( + public readonly Translator $translator = new Translator, + ) { $core = (new CorePlugin)->getManifest(); $this->rules = array_keys($core->rules); $this->coreDecisions = $core->decisions; @@ -129,7 +131,8 @@ public function registerDependencies(string $section, array $sections): void /** * What a name in a suppression comment stands for, as a report is told by: a decision or a section for itself, the - * class of a rule for its requirements; empty when nothing does. + * class of a rule for its requirements, and a name of another tool for the requirements standing for it; empty when + * nothing does. * @return list */ public function expandSuppressedName(string $name): array @@ -145,7 +148,10 @@ public function expandSuppressedName(string $name): array return array_values(array_map(fn(Decision $decision) => $decision->path, $requirements)); } - return []; + return array_values(array_filter( + $this->translator->findPaths($name), + fn(string $path) => ($catalogue->find($path)->kind ?? DecisionKind::Parameter) !== DecisionKind::Parameter, + )); } @@ -306,11 +312,22 @@ public function findPreset(string $preset): ?string /** - * The error of a name of `only`, `fixRisky` or `severity` nobody owns, with the nearest preset. + * The error of a name of `only`, `fixRisky` or `severity` nobody owns, with the decisions that cover it where it belongs + * to another tool, else the nearest preset. */ public function createUnknownNameException(string $name): ConfigurationException { - return new ConfigurationException("Unknown decision, preset or rule `$name`." . self::suggest($name, array_keys($this->presets))); + $message = "Unknown decision, preset or rule `$name`."; + $covered = $this->translator->findPaths($name); + return match (true) { + $covered !== [] => new ConfigurationException( + "$message It is covered by `" + . implode('` and `', $covered) + . '`; `dresscode migrate config` translates a configuration of another tool.', + docs: 'migration#migrate-config', + ), + default => new ConfigurationException($message . self::suggest($name, array_keys($this->presets))), + }; } diff --git a/src/Config/ProjectPackages.php b/src/Config/ProjectPackages.php index 5daf500fa1..78e36bb20c 100644 --- a/src/Config/ProjectPackages.php +++ b/src/Config/ProjectPackages.php @@ -9,6 +9,7 @@ use Composer\Semver\VersionParser; use DressCode\Engine\Helpers; +use DressCode\Tristate; use Nette\Utils\FileSystem; use function is_array, is_string; @@ -36,7 +37,7 @@ public function __construct( /** @var array what the composer.json of the root package says under extra */ public array $rootExtra = [], /** @var array package the project requires itself => its constraint */ - private array $required = [], + public array $required = [], /** * @var array}> installed * package => the version it stands for (null for any), the source it came from, where it lies and its extra @@ -48,6 +49,8 @@ public function __construct( private array $replaced = [], /** the versions of PHP the code must work with, null where nobody said them */ private ?string $php = null, + /** the version of PHP `config.platform.php` sets, which Composer resolves the packages for; null for none */ + public ?string $platformPhp = null, ) { } @@ -103,6 +106,7 @@ public static function read(string $root): self $required, $installed, replaced: array_diff_key($replaced, $installed), + platformPhp: is_string($platform = $composer['config']['platform']['php'] ?? null) ? $platform : null, ); } @@ -115,7 +119,8 @@ public static function read(string $root): self public function withTargets(array $versions): self { return new self( - $this->rootName, $this->rootPath, $this->rootExtra, $this->required, $this->installed, $versions, $this->replaced, $this->php, + $this->rootName, $this->rootPath, $this->rootExtra, $this->required, + $this->installed, $versions, $this->replaced, $this->php, $this->platformPhp, ); } @@ -135,6 +140,7 @@ public function withPhp(string $target): self $this->targets, $this->replaced, Versions::isVersion($target) ? ">=$target" : $target, + $this->platformPhp, ); } @@ -152,6 +158,52 @@ public function has(string $package): bool } + /** + * Whether the project is a library, which says so by its `type`, a project giving none being one as Composer reads + * it, and has no lock of its own; maybe for one with a lock, as an application has. + */ + public function isLibrary(): Tristate + { + return match (true) { + $this->findType() !== 'library' => Tristate::No, + is_file("$this->rootPath/composer.lock") => Tristate::Maybe, + default => Tristate::Yes, + }; + } + + + /** The type of the project, `library` where its composer.json gives none, as Composer reads it. */ + public function findType(): string + { + $type = ComposerJson::read("$this->rootPath/composer.json")['type'] ?? null; + return is_string($type) ? $type : 'library'; + } + + + /** + * The versions the lock of the project holds, a branch without a version of its own among them as null; null for a + * project without a lock. + * @return ?array + */ + public function readLockedVersions(): ?array + { + $lock = $this->rootPath === null ? null : ComposerJson::read("$this->rootPath/composer.lock"); + if ($lock === null) { + return null; + } + + $versions = []; + foreach ([...(array) ($lock['packages'] ?? []), ...(array) ($lock['packages-dev'] ?? [])] as $package) { + if (is_array($package) && is_string($package['name'] ?? null)) { + $version = is_string($package['version'] ?? null) ? ltrim($package['version'], 'v') : null; + $versions[$package['name']] = $version !== null && Versions::isVersion($version) ? $version : null; + } + } + + return $versions; + } + + /** * The versions of the package the code must work with, as a Composer constraint: the target, the constraint the * project requires it with, `*` for a package it does not require, or the version a required branch stands for, @@ -189,26 +241,36 @@ public function findVersion(string $package): ?string /** - * The directories of the installed packages the project supports from an older minor version than the installed one, - * where a declaration may say what the oldest version does not, a deprecation and its replacement above all. - * @return list + * The installed packages the project supports from an older minor version than the installed one, where a declaration + * may say what the oldest version does not, a deprecation and its replacement above all. + * @return array package => the version installed and the lowest one supported */ - public function findNewerInstalledPaths(): array + public function findNewerInstalled(): array { - $paths = []; - foreach ($this->installed as $package => ['version' => $installed, 'path' => $path]) { + $newer = []; + foreach ($this->installed as $package => ['version' => $installed]) { $lowest = $this->findVersion($package); if ( - $path !== null - && $installed !== null + $installed !== null && $lowest !== null && version_compare(self::toMinorVersion($lowest), self::toMinorVersion($installed), '<') ) { - $paths[] = $path; + $newer[$package] = [$installed, $lowest]; } } - return $paths; + return $newer; + } + + + /** + * The directories of the packages `findNewerInstalled()` lists. + * @return list + */ + public function findNewerInstalledPaths(): array + { + $paths = array_map(fn(string $package) => $this->installed[$package]['path'], array_keys($this->findNewerInstalled())); + return array_values(array_filter($paths, fn(?string $path) => $path !== null)); } @@ -221,8 +283,7 @@ public function findUnboundedInstalledPaths(): array { $paths = []; foreach ($this->installed as $package => ['path' => $path]) { - $constraint = $this->findConstraint($package); - if ($path !== null && $constraint !== null && Versions::findLowestVersion($constraint) === null) { + if ($path !== null && $this->isUnbounded($package)) { $paths[] = $path; } } @@ -231,6 +292,17 @@ public function findUnboundedInstalledPaths(): array } + /** + * Whether the project supports the package from no particular version, not requiring it or requiring it without + * a lower bound. + */ + public function isUnbounded(string $package): bool + { + $constraint = $this->findConstraint($package); + return $constraint !== null && Versions::findLowestVersion($constraint) === null; + } + + /** * The first of the required packages the project does not meet; null where it meets every one. * @param array $required package => Composer constraint diff --git a/src/Config/ResolvedProject.php b/src/Config/ResolvedProject.php index 0dbfcb131b..72957afd17 100644 --- a/src/Config/ResolvedProject.php +++ b/src/Config/ResolvedProject.php @@ -45,10 +45,33 @@ public function __construct( private ConfigResolver $resolver, /** the versions the code is written for unless an override says another */ private string $target, + /** @var list presets laid over those of the configuration and beneath its decisions and overrides */ + public array $laid = [], ) { } + /** The project with one more thing the user should be told. */ + public function withWarning(string $warning, ?string $docs = null): self + { + return new self( + $this->config, + $this->root, + $this->resolvedConfig, + $this->phpVersionSource, + [...$this->warnings, $warning => $docs], + $this->upgradingData, + $this->pluginManifests, + $this->projectPackages, + $this->commandLine, + $this->only, + $this->resolver, + $this->target, + $this->laid, + ); + } + + /** Every decision the rules of the run declare, those of the plugins and of the project among them. */ public function getCatalogue(): Catalogue { @@ -56,6 +79,16 @@ public function getCatalogue(): Catalogue } + /** + * What a name of `only` stands for in the project: the decisions of a preset, a section, a package or a rule. + * @throws ConfigurationException + */ + public function expandName(string $name): ExpandedName + { + return $this->resolver->expandName($name); + } + + /** * What the configuration comes to for a file matching those overrides; the same resolution the run uses. * @param list $overrides diff --git a/src/Config/RunnerFactory.php b/src/Config/RunnerFactory.php index 8a7f1f57fd..92cb3d58db 100644 --- a/src/Config/RunnerFactory.php +++ b/src/Config/RunnerFactory.php @@ -10,7 +10,7 @@ use Composer\InstalledVersions; use DressCode\{Analyses, Config, ConfigurationException, Override, Plugin, PluginManifest, Profile, Rule}; use DressCode\Engine\{ - Baseline, FileProcessor, FileProcessors, Helpers, PhpFileProcessor, ReportPolicy, ResultCache, Runner, TypeAnalysisStatus, + Baseline, FileFinder, FileProcessor, FileProcessors, Helpers, PhpFileProcessor, Profiler, ReportPolicy, ResultCache, Runner, TypeAnalysisStatus, }; use Nette\Utils\Finder; use PhpSyntax\Node; @@ -29,6 +29,8 @@ public function __construct( public PluginRegistry $registry = new PluginRegistry, /** whether `phpstan/phpstan` is installed beside DressCode; null asks Composer */ private ?bool $phpstanInstalled = null, + /** the run goes without the types of the code even where the configuration asks for them, as `--no-types` says */ + private bool $withoutTypes = false, ) { } @@ -38,14 +40,27 @@ public function __construct( * override included. * @param ?Profile $commandLine laid over the configuration and its overrides, as --use is * @param ?list $only decisions, sections, presets and classes of rules the run is narrowed to + * @param list $laid presets laid over those of the configuration and beneath its decisions and overrides * @throws ConfigurationException */ - public function resolve(Config $config, string $root, ?Profile $commandLine = null, ?array $only = null): ResolvedProject + public function resolve( + Config $config, + string $root, + ?Profile $commandLine = null, + ?array $only = null, + array $laid = [], + ): ResolvedProject { - [$target, $source] = $this->getPhpTarget($config, $root); - $project = $this->readProjectPackages($config, $root, $target); + [$target, $source] = $this->getPhpTarget($config, $root, $commandLine); + $project = $this->readProjectPackages($config, $root, $target, $commandLine); $packages = PackageDiscovery::discover($project); - [$packagePlugins, $skipped, $unnamed] = $this->admitPackagePlugins($packages->plugins, $config, $commandLine, $project->rootName); + [$packagePlugins, $skipped, $unnamed] = $this->admitPackagePlugins( + $packages->plugins, + array_keys($packages->getDataPackages()), + $config, + $commandLine, + $project->rootName, + ); $visited = []; $plugins = [ ...$this->loadPlugins($packagePlugins, $visited), @@ -55,7 +70,7 @@ public function resolve(Config $config, string $root, ?Profile $commandLine = nu $this->registerProjectRules($config); // a command asking for the types has no run without them $commandTypes = $commandLine instanceof Config ? $commandLine->typeAnalysis : null; - $typesAvailable = ($commandTypes ?? $config->typeAnalysis) === null || $this->isPhpStanInstalled(); + $typesAvailable = !$this->withoutTypes && (($commandTypes ?? $config->typeAnalysis) === null || $this->isPhpStanInstalled()); if ($commandTypes !== null && !$typesAvailable) { throw new ConfigurationException( 'The command needs the types of the code, but `phpstan/phpstan` is not installed beside DressCode.', @@ -63,19 +78,19 @@ public function resolve(Config $config, string $root, ?Profile $commandLine = nu ); } - $resolver = new ConfigResolver($this->registry, $packages->upgradingData, $project, $typesAvailable, $root, $skipped); + $resolver = new ConfigResolver($this->registry, $packages->upgradingData, $project, $typesAvailable, $root, $skipped, $laid); $resolved = $resolver->resolve($config, $target, [], $commandLine, $only); $missing = array_map( fn(string $package) => $package === $project->rootName - ? "The configuration names package `$package` in `targets`, but that is the project itself, whose code is any version of it; skipped." - : "The configuration names package `$package` in `targets`, but it is not installed; skipped.", + ? "Package `$package` is named in `targets`, but that is the project itself, whose code is any version of it; skipped." + : "Package `$package` is named in `targets`, but it is not installed; skipped.", array_filter( - array_keys(array_diff_key($config->targets, [PackageDiscovery::Php => true])), + array_keys(array_diff_key(self::mergeTargets($config, $commandLine), [PackageDiscovery::Php => true])), fn(string $package) => !$project->has($package) || $package === $project->rootName, ), ); $warnings = array_fill_keys([...$packages->warnings, ...$unnamed, ...$missing, ...$resolver->getWarnings()], null); - if (!$typesAvailable) { + if (!$typesAvailable && !$this->withoutTypes) { $warnings['The configuration sets `typeAnalysis: phpstan`, but `phpstan/phpstan` is not installed beside DressCode, so the run goes without the types of the code.'] = 'types#enable'; } @@ -92,6 +107,7 @@ public function resolve(Config $config, string $root, ?Profile $commandLine = nu $only, $resolver, $target, + $laid, ); } @@ -115,6 +131,7 @@ public function createRunner( ?string $configFile = null, bool $fixRisky = false, bool $baseline = true, + ?Profiler $profiler = null, bool $refreshCache = false, array $leftAsWritten = [], ): Runner @@ -136,6 +153,7 @@ public function createRunner( self::resolveAnalysedPaths($config, $root), self::resolveCacheDir($config, $root) . '/phpstan', $phpVersion, + $profiler, $resolution->projectPackages->findNewerInstalledPaths(), $resolution->projectPackages->findUnboundedInstalledPaths(), self::findForeignPaths($root), @@ -143,13 +161,13 @@ public function createRunner( $analyses[Analyses\Types::class] = fn(FileNode $file, string $path) => new Analyses\Types($file, $path, $phpstan); } - $baselineFile = $baseline ? self::loadBaseline($config, $root) : null; + $baselineFile = $baseline ? Baseline::open($config, $root) : null; $registry = $this->registry; $processors = new FileProcessors( array_map(fn(Override $override) => $override->paths, $config->overrides), function (array $overrides) use ( - $resolution, $registry, $analyses, $strict, $baselineFile, $fixRisky, $leftAsWritten, + $resolution, $registry, $analyses, $strict, $baselineFile, $fixRisky, $profiler, $leftAsWritten, ): FileProcessor { $variant = $resolution->resolveFor($overrides); $style = $variant->createStyle(); @@ -174,6 +192,7 @@ function (array $overrides) use ( leftAsWritten: array_fill_keys($leftAsWritten, true), strict: $strict, ), + profiler: $profiler, gates: $variant->getGates(), ); }, @@ -208,12 +227,13 @@ function (array $overrides) use ( return new Runner( $processors, $root, - array_values(array_unique(array_merge(...array_map(fn(Config|PluginManifest $layer) => $layer->excludePaths, $layers)))), + self::collectExcludePaths($resolution), $config->fileExtensions, self::combineSkipWhen($layers), $baselineFile, $resultCache, narrowed: (bool) $resolution->only, + profiler: $profiler, warmUp: isset($phpstan) ? $phpstan->warmUp(...) : null, findDependencies: isset($phpstan) ? $phpstan->getConfigurationFiles(...) : null, findWarnings: isset($phpstan) ? $phpstan->getWarnings(...) : null, @@ -228,6 +248,24 @@ function (array $overrides) use ( } + /** The files of the project a run would take, without building the run. */ + public function createFileFinder(ResolvedProject $resolution): FileFinder + { + return new FileFinder($resolution->root, self::collectExcludePaths($resolution), $resolution->config->fileExtensions); + } + + + /** + * The paths the configuration and the plugins leave out. + * @return list + */ + private static function collectExcludePaths(ResolvedProject $resolution): array + { + $layers = [...$resolution->pluginManifests, $resolution->config]; + return array_values(array_unique(array_merge(...array_map(fn(Config|PluginManifest $layer) => $layer->excludePaths, $layers)))); + } + + private function isPhpStanInstalled(): bool { return $this->phpstanInstalled ?? Analyses\PhpStan::isAvailable(); @@ -239,9 +277,10 @@ private function isPhpStanInstalled(): bool * without it, or the plugin in `use`, its own package needing no name, the others skipped with a warning; a package * named makes its name no preset. * @param array> $plugins package => its plugin + * @param list $carriers the packages shipping upgrading data, which `upgrade` lets in by itself * @return array{list>, list, list} the plugins, the packages skipped, the warnings */ - private function admitPackagePlugins(array $plugins, Config $config, ?Profile $commandLine, ?string $rootName): array + private function admitPackagePlugins(array $plugins, array $carriers, Config $config, ?Profile $commandLine, ?string $rootName): array { $named = [ ...$config->use, @@ -255,6 +294,11 @@ private function admitPackagePlugins(array $plugins, Config $config, ?Profile $c if ($package === $rootName || array_any([$package, $short, $plugin], fn(string $name) => in_array($name, $named, true))) { $admitted[] = $plugin; $this->registry->registerPluginPackage($package, $plugin); + } elseif (in_array($package, $carriers, true)) { + $skipped[] = $package; + $warnings[] = "Package `$package` brings rules that run only where `use` names it" + . ($short === $package ? '' : ", as `$short`") + . ', and in `dresscode upgrade`; skipped, with what its upgrading files set for them.'; } else { $skipped[] = $package; $warnings[] = "Package `$package` brings the plugin `$plugin`, which loads only where `use` names the package or the plugin; skipped."; @@ -361,7 +405,7 @@ private static function resolveCacheFile(Config $config, string $root): string } - private static function resolveCacheDir(Config $config, string $root): string + public static function resolveCacheDir(Config $config, string $root): string { $root = Helpers::canonicalizePath($root); $dir = $config->cacheDir === null ? sys_get_temp_dir() . '/dresscode' : Helpers::toAbsolutePath($config->cacheDir, $root); @@ -596,22 +640,15 @@ private static function hasClosures(ResolvedConfig $resolved, array $analyses): } - /** The configured baseline when its file exists; before the first generation there is none. */ - public static function loadBaseline(Config $config, string $root): ?Baseline - { - return $config->baseline === null ? null : Baseline::load(Helpers::toAbsolutePath($config->baseline, $root)); - } - - /** - * The versions the code is written for unless an override says another, as a Composer constraint: the configured - * one, or those the nearest composer.json allows, or the default one. The running version is never the answer: it - * says nothing about the code being checked. + * The versions the code is written for unless an override says another, as a Composer constraint: the one the + * command line or the configuration says, or those the nearest composer.json allows, or the default one. The running + * version is never the answer: it says nothing about the code being checked. * @return array{string, PhpVersionSource} */ - public function getPhpTarget(Config $config, string $root): array + public function getPhpTarget(Config $config, string $root, ?Profile $commandLine = null): array { - $php = $config->targets['php'] ?? null; + $php = self::mergeTargets($config, $commandLine)[PackageDiscovery::Php] ?? null; $detected = $php === null ? ComposerJson::detectPhpTarget(ComposerJson::findFile($root)) : null; return match (true) { $php !== null => [$php, PhpVersionSource::Configuration], @@ -622,13 +659,29 @@ public function getPhpTarget(Config $config, string $root): array /** - * The packages of the project with the versions the configuration says, PHP among them by the target, which a caller - * that has it already gives. + * The packages of the project with the versions the command line and the configuration say, PHP among them by the + * target, which a caller that has it already gives. */ - public function readProjectPackages(Config $config, string $root, ?string $phpTarget = null): ProjectPackages + public function readProjectPackages( + Config $config, + string $root, + ?string $phpTarget = null, + ?Profile $commandLine = null, + ): ProjectPackages { return ProjectPackages::read($root) - ->withTargets(array_diff_key($config->targets, [PackageDiscovery::Php => true])) - ->withPhp($phpTarget ?? $this->getPhpTarget($config, $root)[0]); + ->withTargets(array_diff_key(self::mergeTargets($config, $commandLine), [PackageDiscovery::Php => true])) + ->withPhp($phpTarget ?? $this->getPhpTarget($config, $root, $commandLine)[0]); + } + + + /** + * The targets of the configuration with those of the command line over them, which an upgrade lays for the versions + * it moves the code to before the upgrading data are cut for them. + * @return array + */ + public static function mergeTargets(Config $config, ?Profile $commandLine): array + { + return [...$config->targets, ...$commandLine->targets ?? []]; } } diff --git a/src/Config/UpgradingData.php b/src/Config/UpgradingData.php index 872052bed5..139d843388 100644 --- a/src/Config/UpgradingData.php +++ b/src/Config/UpgradingData.php @@ -12,9 +12,9 @@ /** - * What one upgrading file says to the project: the maps of the sections the lowest version the code must work with - * reaches, those of the later ones it may run on as what they retire, the rules of the plugin of the package it turns - * on, and what the package declares in its namespaces. + * What one upgrading file, or what DressCode itself knows of a package, says to the project: the maps of the sections + * the lowest version the code must work with reaches, those of the later ones it may run on as what they retire, the + * rules of the plugin of the package it turns on, and what the package declares in its namespaces. * @internal */ final readonly class UpgradingData @@ -39,21 +39,29 @@ public function __construct( public array $reported = [], /** @var array the requirements of the rules of the plugin of the package it turns on => their values */ public array $requirements = [], + /** + * @var array> map => the key of an entry => the version of the section + * it came with and the version `from` says its fix is right from + */ + public array $origins = [], + /** the newest version a section of the file is of, null for what DressCode knows of a package itself */ + public ?string $newest = null, ) { } /** - * The maps and the requirements as the decisions of a layer, each under the path the closure gives it and left out - * where it gives none, the sentence of a retired entry ending with the package and the version that retired it - * (`, since acme/lib 3.0`). + * The maps and the requirements as the decisions of layers, each under the path the closure gives it and left out + * where it gives none: the entries in the layer of the section they came with, the requirements in the layer of the + * file, the sentence of a retired entry ending with the package and the version that retired it + * (`, since acme/lib 3.0`). Every entry stands in one layer, so their order decides nothing. * @param \Closure(string): ?string $findPath the map or the requirement => the path of its decision - * @return array + * @return list}> */ public function buildDecisions(\Closure $findPath): array { $package = $this->package === PackageDiscovery::Php ? 'PHP' : $this->package; - $tree = []; + $layers = $trees = []; foreach ($this->maps as $map => $entries) { $path = $findPath($map); if ($path === null) { @@ -65,16 +73,26 @@ public function buildDecisions(\Closure $findPath): array $entries[$key] = (is_string($sentence) ? rtrim($sentence, '.') . ', ' : '') . "since $package $version"; } - $tree = Helpers::placeValue($tree, $path, $entries); + $bySection = []; + foreach ($entries as $key => $value) { + [$since, $from] = $this->origins[$map][$key] ?? [null, null]; + $layers["$since|$from"] ??= $since === null ? $this->layer : $this->layer->withSection($this->package, $since, $from); + $bySection["$since|$from"][$key] = $value; + } + + foreach ($bySection as $id => $section) { + $trees[$id] = Helpers::placeValue($trees[$id] ?? [], $path, $section); + } } foreach ($this->requirements as $requirement => $value) { $path = $findPath($requirement); if ($path !== null) { - $tree = Helpers::placeValue($tree, $path, $value); + $layers['|'] ??= $this->layer; + $trees['|'] = Helpers::placeValue($trees['|'] ?? [], $path, $value); } } - return $tree; + return array_map(fn(string|int $id) => [$layers[$id], $trees[$id]], array_keys($trees)); } } diff --git a/src/Config/UpgradingFile.php b/src/Config/UpgradingFile.php index f47de71cfc..9c42591cf9 100644 --- a/src/Config/UpgradingFile.php +++ b/src/Config/UpgradingFile.php @@ -139,12 +139,13 @@ public function getSections(): array * What a section above the lowest version retires, where a version the constraint allows reaches it, is reported * without a fix as the entry of a forbidden map (`retire()`), unless a section reached rewrites or forbids the same * thing already. A requirement is turned on where the lowest version reaches its section, and says nothing above it. + * Every entry keeps the section and the version `from` it came with. */ public function selectFor(?string $constraint, Layer $layer): UpgradingData { $lowest = $constraint === null ? null : Versions::findLowestVersion($constraint); $reaches = fn(string $version) => $constraint === null || ($lowest !== null && version_compare($lowest, $version, '>=')); - $maps = $retirements = $retired = $unreached = $reported = []; + $maps = $retirements = $origins = $retired = $unreached = $reported = []; foreach ($this->sections as $since => $section) { $since = (string) $since; $left = []; @@ -157,6 +158,7 @@ public function selectFor(?string $constraint, Layer $layer): UpgradingData } $maps[$map][$key] = self::removeFrom($value); + $origins[$map][$key] = [$since, $from]; unset($retirements[$map][$key]); if (in_array($map, self::ForbiddenMaps, true) && $value !== MemberMapGrammar::Keep) { $retirements[$map][$key] = $since; @@ -172,7 +174,7 @@ public function selectFor(?string $constraint, Layer $layer): UpgradingData if (!Versions::isSubset($constraint, "<$since")) { $reported[] = $since; foreach (self::retire($left) as [$map, $key, $sentence, $version]) { - $retired[$map][$key] = [$sentence, $version ?? $since]; + $retired[$map][$key] = [$sentence, $since, $version]; } } } @@ -188,10 +190,11 @@ public function selectFor(?string $constraint, Layer $layer): UpgradingData } foreach ($retired as $map => $entries) { - foreach ($entries as $key => [$sentence, $version]) { + foreach ($entries as $key => [$sentence, $since, $version]) { if (!isset($written[$map][self::identify($map, $key)])) { $maps[$map][$key] = $sentence; - $retirements[$map][$key] = $version; + $origins[$map][$key] = [$since, $version]; + $retirements[$map][$key] = $version ?? $since; } } } @@ -203,7 +206,32 @@ public function selectFor(?string $constraint, Layer $layer): UpgradingData } } - return new UpgradingData($layer, $this->package, $this->namespaces, $maps, $retirements, $unreached, $reported, $requirements); + return new UpgradingData( + $layer, + $this->package, + $this->namespaces, + $maps, + $retirements, + $unreached, + $reported, + $requirements, + $origins, + $this->findNewestVersion(), + ); + } + + + /** The newest version a section is of, the sections being in order of their versions. */ + private function findNewestVersion(): ?string + { + $newest = null; + foreach ([array_key_last($this->sections), array_key_last($this->requirements)] as $version) { + if ($version !== null && ($newest === null || version_compare((string) $version, $newest, '>'))) { + $newest = (string) $version; + } + } + + return $newest; } diff --git a/src/Config/Versions.php b/src/Config/Versions.php index e3ef185fca..4bcd83aba1 100644 --- a/src/Config/Versions.php +++ b/src/Config/Versions.php @@ -56,12 +56,19 @@ public static function isConstraint(string $constraint): bool /** The versions the constraint allows from the version on, `^8.1 || ^9.0` from 8.3 being `^8.1, >=8.3 || ^9.0, >=8.3`. */ - public static function raiseLowerBound(string $constraint, string $version): string + public static function restrictFrom(string $constraint, string $version): string { - return implode(' || ', array_map( - fn(string $part) => "$part, >=$version", - preg_split('~\s*\|\|?\s*~', trim($constraint)) ?: [], - )); + return implode(' || ', array_map(fn(string $part) => "$part, >=$version", self::splitAlternatives($constraint))); + } + + + /** + * The alternatives of the constraint `||` separates, `^3.2` and `^4.0` of `^3.2 || ^4.0`. + * @return list + */ + public static function splitAlternatives(string $constraint): array + { + return preg_split('~\s*\|\|?\s*~', trim($constraint), flags: PREG_SPLIT_NO_EMPTY) ?: []; } diff --git a/src/Config/enums.php b/src/Config/enums.php index 9ca79f66b2..46c2b04f27 100644 --- a/src/Config/enums.php +++ b/src/Config/enums.php @@ -16,7 +16,7 @@ enum LayerKind case Override; case Preset; - /** an upgrading file of a package */ + /** an upgrading file of a package, or what DressCode itself knows of one */ case Package; /** a name a caller asks about, outside every layer of the configuration */ diff --git a/src/Console/Application.php b/src/Console/Application.php index fc2817e223..96dd35eb69 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -8,6 +8,7 @@ namespace DressCode\Console; use DressCode\{Config, ConfigurationException, ConvergenceException, RuleException}; +use DressCode\Engine\WorkerPool; use Nette\CommandLine\{ColorDepth, Command, Console, HelpRenderer, Parser as CommandLineParser, Prompt, UsageException}; use Nette\Utils\FileSystem; use function extension_loaded; @@ -18,7 +19,7 @@ */ final readonly class Application { - public const Version = '1.3.2'; + public const Version = '1.5.0'; private CommandContext $context; @@ -27,7 +28,8 @@ * @param ?resource $stdout * @param ?resource $stderr * @param ?resource $stdin - * @param ?string $script the script the workers are started with, the one the process was started with by default + * @param ?string $script the script the workers and a run started again are started with, the program of this + * package by default; a command of its own giving `$defaultConfig` names its own, which carries that configuration * @param ?Config $defaultConfig what applies when the project has no configuration file * @param ?bool $xdebug whether Xdebug is loaded; detected when the output is the process's own * @param ?bool $interactive whether the output and the input are a terminal a person answers at; detected from each @@ -52,7 +54,9 @@ public function __construct( out: $out, err: $err, workingDirectory: $cwd ?? (string) getcwd(), - scriptFile: $script ?? $_SERVER['argv'][0] ?? 'dresscode', + scriptFile: $script ?? ($defaultConfig === null + ? WorkerPool::locateProgram() + : throw new \InvalidArgumentException('A command of its own giving a default configuration names its script, `script: __FILE__`, which its workers start so that they have that configuration too.')), defaultConfig: $defaultConfig, xdebug: $xdebug ?? ($stdout === null && extension_loaded('xdebug')), prompt: new Prompt($out, $stdin ?? STDIN, inputTerminal: $interactive), @@ -61,13 +65,16 @@ public function __construct( /** - * Runs the command line and returns the exit code: 0 clean, 1 violations, syntax errors or a refused - * baseline, 2 a file that failed, 3 a mistake of the command line or of the configuration. + * Runs the command line and returns the exit code: 0 clean and complete, 1 violations, syntax errors, a refused + * baseline or work an upgrade or a migration leaves for the user, 2 a file that failed, a commit refused or a command + * that could not look at everything it was to, 3 a mistake of the command line or of the configuration, 4 an upgrade + * waiting for a step outside DressCode. * @param list $argv including the script name */ public function run(array $argv): int { [$context, $out, $err] = [$this->context, $this->context->out, $this->context->err]; + $context->seekOutputEnd(); try { $args = (new CommandLineParser)->parse($this->defineCommandLine(), array_slice($argv, 1)); if ($args->get('--no-color')) { @@ -131,17 +138,35 @@ private function defineCommandLine(): Command $program->addFlag('--help', 'Print this help', standalone: true); $program->addFlag('--version', 'Print the name and the version', standalone: true); + // in the order the help lists them $context = $this->context; - foreach ([ // in the order the help lists them - new Commands\CheckCommand($context), + $program->addCommandsFrom(new Commands\CheckCommand($context)); + $upgrade = $program->addCommand( + 'upgrade', + 'Estimate, plan and carry out an upgrade to newer PHP and libraries', + commandRequired: true, + ); + $upgrade->addCommandsFrom(new Commands\UpgradeCommand($context)); + $upgrade->addText(implode("\n", [ + 'Examples:', + ' dresscode upgrade estimate php 8.4 what an upgrade to PHP 8.4 changes', + ' dresscode upgrade plan nette/utils 4.0 --save a plan for nette/utils 4.0, saved for review', + ' dresscode upgrade run the plan carried out step by step', + ' dresscode upgrade today the code rewritten for the versions required now', + ])); + foreach ([ new Commands\ConfigCommand($context), new Commands\ExplainCommand($context), new Commands\CatalogueCommand($context), + new Commands\InitCommand($context), ] as $handlers) { $program->addCommandsFrom($handlers); } - $program->addText('Exit codes: `0` clean, `1` violations, syntax errors or a refused baseline, `2` a file failed, `3` a mistake of the command line or of the configuration.'); + $program->addCommand('migrate', 'Move over from PHP CS Fixer, PHP_CodeSniffer or Laravel Pint', commandRequired: true) + ->addCommandsFrom(new Commands\MigrateCommand($context)); + + $program->addText('Exit codes: `0` clean and complete, `1` violations, syntax errors, a refused baseline or manual tasks an upgrade or a migration leaves, `2` a file failed, a commit was refused or a command could not look at everything it was to, `3` a mistake of the command line or of the configuration, `4` an upgrade waiting for a step outside DressCode.'); return $program; } } diff --git a/src/Console/CommandContext.php b/src/Console/CommandContext.php index a2722c8994..4a9044a2bf 100644 --- a/src/Console/CommandContext.php +++ b/src/Console/CommandContext.php @@ -7,14 +7,16 @@ namespace DressCode\Console; -use DressCode\{Config, Plugin}; +use DressCode\{Config, ConfigurationException, Plugin}; use DressCode\Config\{Loader, PhpVersionSource, ResolvedProject, RunnerFactory}; use DressCode\Console\Commands\ConfigOptions; -use DressCode\Engine\Helpers; +use DressCode\Engine\{Helpers, WorkerPool}; +use DressCode\Interop\{FormatterConfig, FormatterSearch}; use Nette\CommandLine\{Console, ParseResult, Prompt, UsageException}; use Nette\Neon\Neon; use Nette\Utils\FileSystem; -use function is_string; +use function count, is_string; +use const JSON_PRETTY_PRINT, JSON_THROW_ON_ERROR, JSON_UNESCAPED_SLASHES; /** @@ -24,6 +26,10 @@ */ final readonly class CommandContext { + /** marks a process of DressCode another one started again, which starts none itself */ + private const RerunVariable = 'DRESSCODE_RERUN'; + + public function __construct( /** @var resource */ public mixed $stdout, @@ -35,7 +41,7 @@ public function __construct( public Console $err, /** the directory the paths of the command line are relative to */ public string $workingDirectory, - /** the script the workers are started with */ + /** the script the workers and a run started again are started with */ public string $scriptFile, /** what applies when the project has no configuration file */ public ?Config $defaultConfig, @@ -47,6 +53,40 @@ public function __construct( } + /** + * Runs DressCode again in a new process with the arguments and the variables given, which loads anew what this one + * loaded before the packages changed, its input and output those of this process; null where it cannot, as this + * process is embedded, so that its command line is not its own, or was started again itself. + * @param list $arguments + * @param array $env + */ + public function rerun(array $arguments, array $env = []): ?int + { + if ($this->stdout !== STDOUT || getenv(self::RerunVariable) !== false) { + return null; + } + + $command = [...WorkerPool::buildPhpCommand() ?? [PHP_BINARY], $this->scriptFile, ...$arguments]; + $env = [...getenv(), ...$env, self::RerunVariable => '1']; + $process = proc_open($command, [STDIN, STDOUT, STDERR], $pipes, $this->workingDirectory, $env); + return $process === false ? null : proc_close($process); + } + + + /** + * A process started again writes after what the one that started it wrote, as a file of the output it inherits + * starts at its beginning on Windows. + */ + public function seekOutputEnd(): void + { + if (getenv(self::RerunVariable) !== false) { + foreach ([$this->stdout, $this->stderr] as $stream) { + @fseek($stream, 0, SEEK_END); // @ a terminal or a pipe has no end to seek + } + } + } + + /** * The project as the command line names it, resolved: the factory that resolved it, its resolution and the file * of the configuration. @@ -64,16 +104,18 @@ public function resolveProject(ParseResult $args, ConfigOptions $options, array /** * The configuration, the root directory, the file it came from, and the layer the command line lays over them. + * @param ?Config $default the configuration of a command that runs without a file * @return array{config: Config, root: string, file: ?string, commandLine: ?Config} * @throws UsageException */ - public function loadConfig(ParseResult $args, ConfigOptions $options): array + public function loadConfig(ParseResult $args, ConfigOptions $options, ?Config $default = null): array { [$config, $root, $file] = Loader::load( $options->config === null ? null : Helpers::toAbsolutePath($options->config, $this->workingDirectory), $this->workingDirectory, // without a configuration file the run has only what the command line uses - $this->defaultConfig ?? ($options->use ? new Config : null), + $this->defaultConfig ?? $default ?? ($options->use ? new Config : null), + $options->root === null ? null : Helpers::toAbsolutePath($options->root, $this->workingDirectory), ); return ['config' => $config, 'root' => $root, 'file' => $file, 'commandLine' => $this->readCommandLine($args, $options)]; } @@ -128,8 +170,8 @@ public function resolvePath(string $path): string } - /** Where the rules come from, which is nothing the command line shows. */ - public function writeHeader(?string $configFile, ResolvedProject $resolution): void + /** Where the rules come from and the baseline that holds violations back, which is nothing the command line shows. */ + public function writeHeader(?string $configFile, ResolvedProject $resolution, ?string $baselineFile = null): void { [$config, $commandLine] = [$resolution->config, $resolution->commandLine]; $this->out->writeLine(self::formatName($this->out)); @@ -139,13 +181,18 @@ public function writeHeader(?string $configFile, ResolvedProject $resolution): v [...$config->plugins, ...$commandLine instanceof Config ? $commandLine->plugins : []], ), ...$config->use, + ...$resolution->laid, ...$commandLine->use ?? [], ]; - $this->out->writeLine($this->out->color('gray', 'Config ') . ($configFile === null + $this->out->writeLine(Markup::formatLabel($this->out, 'Config') . ($configFile === null ? 'none, using ' . (implode(', ', $use) ?: 'nothing') : FileSystem::platformSlashes($configFile))); + if ($baselineFile !== null) { + $this->out->writeLine(Markup::formatLabel($this->out, 'Baseline') . FileSystem::platformSlashes($baselineFile)); + } + $this->out->writeLine( - $this->out->color('gray', 'Target ') . Markup::highlightCode($this->out, 'PHP ' . self::describePhpVersion($resolution)), + Markup::formatLabel($this->out, 'Target') . Markup::highlightCode($this->out, 'PHP ' . self::describePhpVersion($resolution)), ); } @@ -177,6 +224,16 @@ public function write(string $text): void } + /** + * The document of the format `json` on the output, as it is. + * @param array $data + */ + public function writeJson(array $data): void + { + fwrite($this->stdout, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"); + } + + /** A message of the tool on the error output, which keeps the output for what the command produces. */ public function writeNote(string $text): void { @@ -192,4 +249,44 @@ public function writeError(string $message, ?string $docs = null, string $detail $this->err->writeLine(Markup::formatDocsLink($this->err, $docs)); } } + + + /** + * The configuration of the formatter the project runs: the file named, else of those `FormatterSearch` finds the one + * the user chooses in a terminal, elsewhere the first, as it is at the end of the input, the others told. + * @throws ConfigurationException for a file named that cannot be read + */ + public function chooseFormatterConfig(string $root, ?string $named, bool $interactive): FormatterChoice + { + if ($named !== null) { + return new FormatterChoice(FormatterConfig::fromFile($root, $this->resolvePath($named)), named: true); + } + + $candidates = FormatterSearch::find($root); + if (!$interactive || count($candidates) < 2) { + return new FormatterChoice(($candidates[0] ?? null)?->read($root), array_slice($candidates, 1)); + } + + $menu = ''; + foreach ($candidates as $i => $candidate) { + $menu .= ' [' . ($i + 1) . '] ' . $candidate->format() . "\n"; + } + + $this->out->clearStatus(); + $this->write("\nThe project has the configurations of several formatters:\n$menu"); + $choice = $this->prompt->choose('Which one does it run?', array_map(strval(...), range(1, count($candidates))), '1'); + return new FormatterChoice(($candidates[(int) $choice - 1] ?? $candidates[0])->read($root)); + } + + + /** + * Whether the rules of the configuration chosen may be read: data always, PHP, which reading runs, where the file was + * named or the user agrees in a terminal, asked `$question` after what reading does. + */ + public function confirmReadingRules(FormatterChoice $choice, bool $interactive, string $question): bool + { + $config = $choice->config; + return $config !== null && (!$config->formatter->runsCode() || $choice->named || ($interactive + && $this->prompt->choose("`$config->file` is PHP, which reading runs. $question", ['y', 'n'], 'n') === 'y')); + } } diff --git a/src/Console/Commands/CatalogueCommand.php b/src/Console/Commands/CatalogueCommand.php index c355676233..82da92da90 100644 --- a/src/Console/Commands/CatalogueCommand.php +++ b/src/Console/Commands/CatalogueCommand.php @@ -14,8 +14,8 @@ /** - * The command `catalogue`: every decision of the catalogue with its description, those the configuration makes marked; - * or the catalogue as data, the values of the standards included. + * The command `catalogue`: every decision of the catalogue with its description and the names of other tools standing + * for it, those the configuration makes marked; or the catalogue as data, the values of the standards included. * @internal */ final readonly class CatalogueCommand @@ -45,6 +45,7 @@ public function catalogue( { [$factory, $resolution] = $this->context->resolveProject($args, $configuration, $only); $catalogue = $resolution->getCatalogue(); + $translator = $factory->registry->translator; $out = $this->context->out; if ($format === 'json') { $data = $catalogue->toArray(); @@ -58,6 +59,11 @@ public function catalogue( } } + foreach ($data['decisions'] as $path => &$decision) { + $decision['covers'] = $translator->findForeignNames([$path]); + } + + unset($decision); $out->write(Json::encode($data, pretty: true) . "\n"); return 0; } @@ -72,10 +78,12 @@ public function catalogue( ); foreach ($decisions as $path => $decision) { $set = isset($made[$path]) && $made[$path]->layers !== [] && !$made[$path]->value->isKept(); + $covers = $translator->findForeignNames([$path]); $out->writeLine( ($set ? '*' : ' ') . ' ' . Ansi::pad($out->color($set ? 'white' : null, $path), 50) - . ' ' . Markup::highlightCode($out, $decision->description), + . ' ' . Markup::highlightCode($out, $decision->description) + . ($covers ? $out->color('gray', ' (covers ' . implode(', ', $covers) . ')') : ''), ); } diff --git a/src/Console/Commands/CheckCommand.php b/src/Console/Commands/CheckCommand.php index 9682953e77..7da515e1d9 100644 --- a/src/Console/Commands/CheckCommand.php +++ b/src/Console/Commands/CheckCommand.php @@ -7,13 +7,8 @@ namespace DressCode\Console\Commands; -use DressCode\{Config, Reporter, Reporters}; -use DressCode\Config\{Loader, PluginRegistry, ResolvedProject}; -use DressCode\Console\{CommandContext, Markup, ProgressBar, RiskReview}; -use DressCode\Engine\{Baseline, FileSummary, Helpers, LineRanges, Runner, RunResult, Worker, WorkerPool}; -use Nette\CommandLine\{Attributes as Cli, Console, ParseResult, UsageException}; -use Nette\Utils\FileSystem; -use function count, in_array, is_string, sprintf, strlen; +use DressCode\Console\{CommandContext, RunExecutor}; +use Nette\CommandLine\{Attributes as Cli, ParseResult}; /** @@ -22,16 +17,19 @@ */ final readonly class CheckCommand { - public function __construct( - private CommandContext $context, - ) { + private RunExecutor $executor; + + + public function __construct(CommandContext $context) + { + $this->executor = new RunExecutor($context); } #[Cli\Command('Report the violations')] public function check(ParseResult $args, RunOptions $run, CheckOptions $check, ConfigOptions $configuration): int { - return $this->run($args, $configuration, $run, $check); + return $this->executor->run($args, $configuration, $run, $check); } @@ -45,554 +43,13 @@ public function fix( ConfigOptions $configuration, ): int { - return $this->run($args, $configuration, $run, $check, fix: true, askRisky: (bool) $askRisky); + return $this->executor->run($args, $configuration, $run, $check, fix: true, askRisky: (bool) $askRisky); } #[Cli\Command('Write the violations a fix leaves into the baseline file of the configuration, once a fix changes nothing')] public function baseline(ParseResult $args, RunOptions $run, ConfigOptions $configuration): int { - return $this->run($args, $configuration, $run, generate: true); - } - - - /** - * @param ?CheckOptions $check what check and fix take beyond the run, null for the baseline - * @param bool $generate the baseline command writes the configured baseline, it never reads it - */ - private function run( - ParseResult $args, - ConfigOptions $configuration, - RunOptions $run, - ?CheckOptions $check = null, - bool $fix = false, - bool $generate = false, - bool $askRisky = false, - ): int - { - // `fix --stdin` is the formatter of an editor, which reads the text and the exit code alone - $formatter = $fix && $check?->stdin !== null; - foreach ([ - '--format' => $run->format, - '--diff' => $check?->diff, - '--max-warnings' => $check?->maxWarnings, - '--ask-risky' => $askRisky ?: null, - '--lines' => $check?->lines ?: null, - '--before' => $check?->before, - ] as $option => $value) { - if ($formatter && $value !== null) { - throw new UsageException( - "`fix --stdin` writes the fixed code alone, so it takes no `$option`; `check --stdin` reports on the code in any format.", - $args->command, - ); - } - } - - [$factory, $resolution, $configFile] = $this->context->resolveProject($args, $configuration, $check->only ?? []); - [$config, $root] = [$resolution->config, $resolution->root]; - $fixRisky = in_array(true, $check->fixRisky ?? [], true); // a bare `--fix-risky`, the names given go to the configuration - if ($run->worker !== null) { // the parent keeps the cache; the baseline decides what is reported - $runner = $factory->createRunner( - $resolution, - strict: (bool) $run->strictRules, - cache: false, - fixRisky: $fixRisky, - baseline: !$generate, - ); - ($runner->warmUp)?->__invoke(); // before connecting, which is what starts the other workers - return Worker::run($run->worker, $runner, $fix); - } - - $options = [ - 'strict' => (bool) $run->strictRules, - // the counts of a file served from the cache are those the baseline left - 'cache' => !$generate, - 'configFile' => $configFile, - 'fixRisky' => $fixRisky, - 'baseline' => !$generate, - 'refreshCache' => (bool) $run->noCache, - ]; - $runner = $factory->createRunner($resolution, ...$options); - [$out, $err] = [$this->context->out, $this->context->err]; - $format = self::resolveFormat($run->format, detect: true); - // only the parent warns, a worker has returned above; an agent can do nothing about the configuration - foreach ($formatter || $format === 'agent' ? [] : $resolution->warnings as $warning => $docs) { - $err->writeLine(Markup::highlightCode($err, "Warning: $warning", 'yellow')); - if ($docs !== null) { - $err->writeLine(Markup::formatDocsLink($err, $docs)); - } - } - - $stdinPath = $check?->stdin; - $paths = array_values(array_unique(array_map($this->context->resolvePath(...), $run->paths))); - $maxWarnings = $check?->maxWarnings; - if ($askRisky && ($check?->fixRisky || is_string($stdinPath))) { - throw new UsageException( - '`--ask-risky` asks about the risky fixes one by one, so it goes with neither `--fix-risky` nor `--stdin`.', - $args->command, - ); - } elseif ($askRisky && ($format !== 'console' || !$this->context->prompt->isInteractive())) { - throw new UsageException( - '`--ask-risky` asks in an interactive terminal; without one, allow the risky fixes with `--fix-risky=`.', - $args->command, - ); - } - - $lines = $check?->lines ? new LineRanges($check->lines) : null; - $before = $check?->before; - if ($lines && $format !== 'agent') { - throw new UsageException('`--lines` narrows what the `agent` format reports, so it goes with no other format.', $args->command); - } elseif ($before !== null && ($format !== 'agent' || is_string($stdinPath))) { - throw new UsageException( - '`--before` tells the `agent` format what an edit of a file did, so it goes with no other format and no `--stdin`.', - $args->command, - ); - } elseif ($lines && $run->strictRules) { - throw new UsageException( - '`--lines` names the lines of the file as it was read, which `--strict-rules` reads again in every round.', - $args->command, - ); - } - - if (is_string($stdinPath)) { - if ($paths) { - throw new UsageException('Paths cannot be combined with `--stdin`.', $args->command); - } - - return $formatter - ? $this->runFormatter($runner, $stdinPath, (bool) $run->skipExcluded, $config->paths) - : $this->runStdin( - self::resolveFormat($run->format, detect: false), // the caller of stdin is an editor or a hook waiting for the format it asked for - (bool) $check->diff, - $runner, - $factory->registry, - $resolution, - $stdinPath, - $maxWarnings, - (bool) $run->skipExcluded, - $config->paths, - $lines, - ); - } - - $scope = $paths ? $runner->narrowPaths($paths, $config->paths) : $config->paths; - if (!$scope) { - throw new UsageException('No paths given and none configured.', $args->command); - } - - // the machine-readable formats must not be prefaced, and a generated baseline is not a report - $preface = !$generate && in_array($format, ['console', 'github'], true); - if ($preface) { - if ($this->context->xdebug) { - $err->writeLine('Warning: Xdebug is loaded and makes the run many times slower.', 'red'); - } - - $this->context->writeHeader($configFile, $resolution); - $out->setStatus($out->color('gray', $fix ? 'Fixing ' : 'Checking ') . 'looking for files…'); - } - - try { - $files = $runner->findFiles($scope, skipExcluded: (bool) $run->skipExcluded, configured: $config->paths); - } finally { - $out->clearStatus(); - } - - if ($preface) { - $this->writeScope(files: $files, paths: $scope, root: $root, fix: $fix, narrowed: $paths && $scope !== $paths); - } - - if (($lines || $before !== null) && count($files) > 1) { - throw new UsageException( - ($lines ? '`--lines` names the lines of one file' : '`--before` names the text of one file') - . ', but ' . count($files) . ' files are to be run.', - $args->command, - ); - } elseif ($before !== null && $files) { - $code = @file_get_contents($this->context->resolvePath($before)); // @ the failure is said below - if ($code === false) { - throw new UsageException("Cannot read file `$before`, which `--before` names.", $args->command); - } - - $left = $this->findBrokenDecisions($runner, $files[0], $code); - $runner = $factory->createRunner($resolution, ...$options, leftAsWritten: $left); - $lines = new LineRanges($check->lines, array_fill_keys($left, true)); - } - - // a worker costs about the processing of a few files to start, so by default one for every four files at most - $jobs = $run->jobs ?? max(1, min(WorkerPool::detectCpuCount(), intdiv(count($files), 4))); - $command = $jobs > 1 && $files ? $this->buildWorkerCommand($args, $configuration, $run, $check) : null; - $workers = $command - ? new WorkerPool($command, $jobs, $this->context->workingDirectory, warmFirst: $runner->warmUp !== null) - : null; - $progress = !$generate && $format === 'console' && count($files) > 1 && $out->isTerminal() - ? new ProgressBar($out, count($files)) - : null; - $onProgress = $progress === null ? null : $progress->advance(...); - - try { - if ($generate) { - return $this->generateBaseline($runner, $config, $root, $configFile, $files, $workers, $format); - } - - $reporter = $this->createReporter( - (bool) $check?->diff, - $out, - $this->context->stdout, - $root, - $format, - $factory->registry, - $runner, - $resolution, - $run->paths, - ); - $result = $runner->run($files, $fix, $reporter, $workers, $onProgress, $maxWarnings, $lines); - return $askRisky - ? $this->review($runner, $result, $files, $root, $maxWarnings, $factory->registry) - : $result->getExitCode(); - - } finally { - $progress?->clear(); // an error must not be written into the bar - } - } - - - /** - * The decisions the file as it was before an edit broke, which the run then leaves as the file writes them. - * @return list - */ - private function findBrokenDecisions(Runner $runner, string $file, string $code): array - { - if ($runner->isKnownClean($file, $code)) { // the text the previous fix of the file wrote, most often - return []; - } - - $decisions = array_column($runner->processCode($file, $code)->violations, 'decision'); - return array_values(array_unique($decisions)); - } - - - /** - * Reports on the code read from stdin as if it were the file at the path; nothing of a path the excluded ones leave - * out where the caller asks to skip it. - * @param list $configured - */ - private function runStdin( - string $format, - bool $diff, - Runner $runner, - PluginRegistry $registry, - ResolvedProject $resolution, - string $path, - ?int $maxWarnings, - bool $skipExcluded, - array $configured, - ?LineRanges $lines, - ): int - { - $reporter = $this->createReporter( - $diff, - $this->context->out, - $this->context->stdout, - $resolution->root, - $format, - $registry, - $runner, - $resolution, - [$path], - ); - $code = (string) stream_get_contents($this->context->stdin); - $absolute = $this->context->resolvePath($path); - $skipped = $skipExcluded && $runner->excludes($absolute, $configured); - $reporter->start($runner->createRunInfo(false, $skipped ? 0 : 1)); - $result = $skipped ? null : $runner->processPath($absolute, fix: false, code: $code); - $result = $result === null ? null : $lines?->select($result, fix: false) ?? $result; - if ($result) { - $reporter->reportFile($result); - } - - $run = new RunResult($result ? [FileSummary::of($result)] : [], fix: false, maxWarnings: $maxWarnings); - $reporter->finish($run); - return $run->getExitCode(); - } - - - /** - * Formats the code read from stdin as if it were the file at the path, for an editor: the whole fixed code goes to - * stdout with exit code 0, whatever it still violates; a syntax error or a failure leaves the code as it came, its - * one line on stderr, and so does a path the excluded ones leave out, without a word. - * @param list $configured - */ - private function runFormatter(Runner $runner, string $path, bool $skipExcluded, array $configured): int - { - $code = (string) stream_get_contents($this->context->stdin); - $path = $this->context->resolvePath($path); - $result = $skipExcluded && $runner->excludes($path, $configured) - ? null - : $runner->processPath($path, fix: false, code: $code); - [$output, $error, $exit] = match (true) { - $result?->syntaxError !== null => [$code, "$result->path:$result->syntaxErrorLine: Syntax error: $result->syntaxError", 1], - $result?->failure !== null => [$code, "$result->path: " . strtok($result->failure, "\n"), 2], - default => [$result->output ?? $code, null, 0], - }; - - if ($error !== null) { - fwrite($this->context->stderr, "$error\n"); - } - - return fwrite($this->context->stdout, $output) === strlen($output) ? $exit : 2; - } - - - /** - * Asks about the risky fixes the fix left and makes those accepted, then says how it ended; the exit code is that of - * the files as the review left them. - * @param list $files - */ - private function review(Runner $runner, RunResult $result, array $files, string $root, ?int $maxWarnings, PluginRegistry $registry): int - { - $out = $this->context->out; - if ($result->countRefused() === 0) { - $out->writeLine("\n" . $out->color($result->getExitCode() === 0 ? 'white/green' : 'white/red', 'REVIEWED no risky fixes to ask about')); - return $result->getExitCode(); - } - - $made = new RiskReview($runner, $out, $this->context->prompt, $root, $registry)->review($result); - $final = $runner->run($files, true, new Reporters\NullReporter, maxWarnings: $maxWarnings); - $left = $final->countRefused(); - $out->writeLine( - "\n" . $out->color( - $final->getExitCode() === 0 ? 'white/green' : 'white/red', - sprintf('REVIEWED %d risky %s made, %s', $made, $made === 1 ? 'fix' : 'fixes', $left ? "$left left" : 'none left'), - ), - ); - return $final->getExitCode(); - } - - - /** - * What the rules are applied to: the scope reaches wherever the configuration was found, - * not where the run was started. - * @param list $files relative to the root, or absolute when outside it - * @param list $paths they were found under these - * @param bool $narrowed a directory named on the command line was narrowed to the configured paths - */ - private function writeScope(array $files, array $paths, string $root, bool $fix, bool $narrowed): void - { - $out = $this->context->out; - $absolute = array_map(fn(string $file) => FileSystem::isAbsolute($file) ? $file : "$root/$file", $files); - $scope = count($files) === 1 - ? FileSystem::platformSlashes($absolute[0]) - : sprintf('%d files in %s', count($files), FileSystem::platformSlashes(self::findCommonDirectory($absolute) ?: $root)); - $out->write($files - ? $out->color('gray', $fix ? 'Fixing ' : 'Checking ') . $scope - . ($narrowed ? $out->color('gray', ', narrowed to the configured paths') : '') . "\n\n" - : $out->color('yellow', sprintf( - ($fix ? 'Nothing to fix' : 'Nothing to check') . ': no file in %s', - implode(', ', array_map(FileSystem::platformSlashes(...), $paths)), - )) . "\n"); - } - - - /** - * The directory all the files share; that is the scope the run really has. - * @param list $files - */ - private static function findCommonDirectory(array $files): string - { - $common = null; - foreach ($files as $file) { - $segments = explode('/', $file); - array_pop($segments); - if ($common === null) { - $common = $segments; - continue; - } - - $length = 0; - while (isset($common[$length], $segments[$length]) && $common[$length] === $segments[$length]) { - $length++; - } - - $common = array_slice($common, 0, $length); - } - - return implode('/', $common ?? []); - } - - - /** - * The command line of a worker: the same PHP, the same command and configuration; the paths come over the - * connection. Null where there is no PHP to start one with, and the run stays in the process. - * @param ?CheckOptions $check null for the baseline - * @return ?list - */ - private function buildWorkerCommand(ParseResult $args, ConfigOptions $configuration, RunOptions $run, ?CheckOptions $check): ?array - { - $php = WorkerPool::buildPhpCommand(); - if ($php === null) { - return null; - } - - $command = [ - ...$php, - $this->context->scriptFile, - (string) $args->command->name, - '--no-color', - ]; - foreach ([ - '--config' => (array) $configuration->config, - '--use' => $configuration->use, - '--set' => $configuration->set, - '--severity' => $configuration->severity, - '--only' => $check->only ?? [], - ] as $option => $values) { - foreach ($values as $value) { - $command[] = "$option=$value"; // a value starting with a dash would read as an option - } - } - - if ($run->strictRules) { - $command[] = '--strict-rules'; - } - - foreach ($check->fixRisky ?? [] as $value) { // what a worker may fix has to be what the parent was asked for - $command[] = is_string($value) ? "--fix-risky=$value" : '--fix-risky'; - } - - return $command; - } - - - /** - * Runs the check without the current baseline over code a fix leaves alone and writes what remains into the - * configured baseline file, or into the default one beside the configuration, which the user then has to name - * to make it apply; code a fix would still change, or a file that fails or does not parse, is refused. - * @param list $files - */ - private function generateBaseline( - Runner $runner, - Config $config, - string $root, - ?string $configFile, - array $files, - ?WorkerPool $workers, - string $format, - ): int - { - $name = $config->baseline ?? self::getDefaultBaselineName($configFile); - $file = Helpers::toAbsolutePath($name, $root); - $run = $runner->run($files, fix: false, reporter: new Reporters\NullReporter, workers: $workers); - $changed = $failed = []; - foreach ($run->files as $result) { - if ($result->failure !== null || $result->syntaxError !== null) { - $failed[] = $result->path; - } elseif ($result->changed) { - $changed[] = $result->path; - } - } - - if ($changed || $failed) { - $counts = array_filter([ - $changed ? 'a fix would change ' . Markup::formatCount(count($changed), 'file') : null, - $failed ? Markup::formatCount(count($failed), 'file') . ' failed' : null, - ]); - $paths = [...$changed, ...$failed]; - $this->context->writeError( - 'The baseline is generated over code a fix leaves alone: ' . implode(', ', $counts) . '. Run `fix` first.', - 'suppressing#baseline', - implode('', array_map(fn(string $path) => " $path\n", array_slice($paths, 0, 10))) - . (count($paths) > 10 ? ' and ' . (count($paths) - 10) . " more\n" : ''), - ); - return 1; - } - - $baseline = Baseline::fromResults($run->files); - $baseline->save($file); - $message = 'Baseline with ' . Markup::formatCount($baseline->count(), 'violation') . " written to `$name`.\n"; - if ($config->baseline === null) { - $message .= "Name it under `baseline` in the configuration to make it apply.\n"; - } - - // every other format keeps its stream to itself - $format === 'console' ? $this->context->write($message) : $this->context->writeNote($message); - return 0; - } - - - /** The baseline is written in the format the configuration is written in, so there is no third format. */ - private static function getDefaultBaselineName(?string $configFile): string - { - return 'dresscode-baseline.' . (Loader::detectFormat($configFile ?? '') ?? 'neon'); - } - - - /** - * @param resource $stream the same place as the console; a machine-readable format is no text of a console - * @param string $root the paths of the results are relative to it - * @param list $paths the paths as the command line gave them - */ - private function createReporter( - bool $diff, - Console $console, - $stream, - string $root, - string $format, - PluginRegistry $registry, - Runner $runner, - ResolvedProject $resolution, - array $paths, - ): Reporter - { - return match ($format) { - 'lines' => new Reporters\LinesReporter($stream, $root, Helpers::canonicalizePath($this->context->workingDirectory)), - 'agent' => new Reporters\AgentReporter( - $stream, - $root, - Helpers::canonicalizePath($this->context->workingDirectory), - givenPaths: array_combine( - array_map(fn(string $path) => Helpers::canonicalizePath($this->context->resolvePath($path)), $paths), - $paths, - ), - findSuppressionExamples: function (string $path) use ($runner, $resolution): array { - $config = $resolution->resolveFor($runner->findOverridesFor($path)); - $examples = []; - foreach ($config->suppressionComments as $pattern => $decisions) { - foreach (isset($config->suppressionExamples[$pattern]) ? $decisions : [] as $decision) { - $examples[$decision][] = $config->suppressionExamples[$pattern]; - } - } - - return $examples; - }, - ), - 'json' => new Reporters\JsonReporter($stream), - 'checkstyle' => new Reporters\CheckstyleReporter($stream), - 'github' => new Reporters\GithubReporter($stream, $root, self::findEnv('GITHUB_WORKSPACE')), - default => new Reporters\ConsoleReporter( - $console, - diff: $diff, - root: $root, - cwd: Helpers::canonicalizePath($this->context->workingDirectory), - findDecisionUrl: $registry->findUrl(...), - ), - }; - } - - - /** - * The format asked for, or the one the surroundings call for: annotations when the run is a step - * of a GitHub Actions workflow, where nobody reads the log. - * @param bool $detect let the surroundings decide when the command line does not - */ - private static function resolveFormat(?string $format, bool $detect): string - { - return $format ?? ($detect && self::findEnv('GITHUB_ACTIONS') === 'true' ? 'github' : 'console'); - } - - - private static function findEnv(string $name): ?string - { - $value = getenv($name); - return $value === false || $value === '' ? null : $value; + return $this->executor->run($args, $configuration, $run, generate: true); } } diff --git a/src/Console/Commands/ConfigCommand.php b/src/Console/Commands/ConfigCommand.php index d5f7a6498c..3624a67ef1 100644 --- a/src/Console/Commands/ConfigCommand.php +++ b/src/Console/Commands/ConfigCommand.php @@ -9,7 +9,7 @@ use DressCode\Config; use DressCode\Config\RunnerFactory; -use DressCode\Console\{CommandContext, ConfigPrinter}; +use DressCode\Console\{CommandContext, ConfigPrinter, Markup}; use DressCode\Engine\Helpers; use Nette\CommandLine\{Attributes as Cli, ParseResult, UsageException}; use Nette\Utils\FileSystem; @@ -85,7 +85,7 @@ public function config( $this->context->writeHeader($configFile, $resolution); if (is_string($file)) { - $out->writeLine($out->color('gray', 'File ') . FileSystem::platformSlashes($file)); + $out->writeLine(Markup::formatLabel($out, 'File') . FileSystem::platformSlashes($file)); } $out->write($printer->print($out)); diff --git a/src/Console/Commands/ConfigOptions.php b/src/Console/Commands/ConfigOptions.php index 1f8f3d6dd9..cd8c3ff83a 100644 --- a/src/Console/Commands/ConfigOptions.php +++ b/src/Console/Commands/ConfigOptions.php @@ -46,10 +46,37 @@ public function __construct( normalizer: [self::class, 'checkSeverity'], )] public array $severity = [], + /** the root of the run in place of the directory of the configuration file, for a configuration written elsewhere */ + #[Cli\Option(hidden: true)] + public ?string $root = null, ) { } + /** + * The options as the command line of another process of DressCode gives them, each value glued to its option, as + * one starting with a dash would read as an option of its own. + * @return list + */ + public function toArguments(): array + { + $arguments = []; + foreach ([ + '--config' => (array) $this->config, + '--use' => $this->use, + '--set' => $this->set, + '--severity' => $this->severity, + '--root' => (array) $this->root, + ] as $option => $values) { + foreach ($values as $value) { + $arguments[] = "$option=$value"; + } + } + + return $arguments; + } + + /** * The normalizer of `--use`, which names a preset or a plugin, not a decision. */ diff --git a/src/Console/Commands/DataPackageOffer.php b/src/Console/Commands/DataPackageOffer.php new file mode 100644 index 0000000000..daed5d9989 --- /dev/null +++ b/src/Console/Commands/DataPackageOffer.php @@ -0,0 +1,77 @@ + $config, 'root' => $root] = $this->context->loadConfig($args, $configuration, new Config(paths: ['.'])); + $installation = DataPackageInstallation::find($root, RunnerFactory::resolveCacheDir($config, $root)); + if ($installation === null) { + return false; + } + + $names = implode(', ', array_map(fn(string $package) => "`$package`", $installation->packages)); + $reason = 'The libraries of the project have upgrading data in ' . $names . ', which DressCode does not have'; + if ($installation->directory === null) { + throw new ConfigurationException("$reason, and its installation is not one Composer can add to; install the package beside DressCode."); + } + + $command = $installation->formatCommand(); + if (!$yes) { + if (!$mayAsk) { + throw new ConfigurationException("$reason; install it with `$command`, or let `dresscode " . TodayUpgrade::Command . ' --yes` do it.'); + } + + $this->context->out->writeLine("$reason."); + $where = $installation->intoProject + ? 'into the project, which changes its `composer.json`' + : "beside DressCode in `$installation->directory`"; + if ($this->context->prompt->choose("Install it $where?", ['y', 'n'], 'y') !== 'y') { + return false; + } + } + + // the progress goes to standard error, so that the output of the run stays the one document a format asks for + $this->context->err->writeLine("Installing $names\u{2026}"); + try { + $installation->install(); + } catch (ComposerException $e) { + throw new ConfigurationException( + "Composer did not install the upgrading data with `$command`: {$e->getMessage()}", + previous: $e, + ); + } + + return true; + } +} diff --git a/src/Console/Commands/EstimateOptions.php b/src/Console/Commands/EstimateOptions.php new file mode 100644 index 0000000000..43a16e5c6c --- /dev/null +++ b/src/Console/Commands/EstimateOptions.php @@ -0,0 +1,50 @@ + */ + #[Cli\Option('Files or directories within the configured paths; all of them when omitted', valueName: 'path', repeatable: true)] + public array $paths = [], + #[Cli\Option( + 'Output format: `console`, or `json` for a tool or an AI agent, which never asks', + alias: '-f', + enum: ['console', 'json'], + )] + public ?string $format = null, + #[Cli\Flag( + 'Measure with the types of the code from PHPStan, without asking; `--no-types` without them', + negatable: true, + )] + public ?bool $types = null, + #[Cli\Flag('Measure without asking the network: what Packagist offers as remembered, what Composer knows from the lock')] + public ?bool $offline = null, + #[Cli\Option( + 'Number of worker processes; by default the number of processors, at most one per four files; `1` runs in-process', + valueName: 'n', + normalizer: [RunOptions::class, 'parsePositiveInt'], + )] + public ?int $jobs = null, + /** the address of the parent; a worker started by WorkerPool */ + #[Cli\Option(hidden: true)] + public ?string $worker = null, + /** the file the parent wrote the check a worker of the estimate repeats into */ + #[Cli\Option(hidden: true)] + public ?string $checkFile = null, + ) { + } +} diff --git a/src/Console/Commands/InitCommand.php b/src/Console/Commands/InitCommand.php new file mode 100644 index 0000000000..e60f06667b --- /dev/null +++ b/src/Console/Commands/InitCommand.php @@ -0,0 +1,309 @@ + $use + * @param list $set + */ + #[Cli\Command('Measure the code of the project and write a `dresscode.neon` to fit')] + public function init( + ParseResult $args, + #[Cli\Option( + 'The standard and the sets to write; the standard nearest to the code by default', + valueName: 'name', + repeatable: true, + normalizer: [ConfigOptions::class, 'checkPreset'], + )] + array $use = [], + #[Cli\Option( + 'Limit the files the first fix changes (default 1%, 2 to 20); a decision over it is written as `keep`. The indentation, line endings, long lines and your answers do not count', + valueName: 'files', + normalizer: [RunOptions::class, 'parsePositiveInt'], + )] + ?int $budget = null, + #[Cli\Option( + 'Write a decision whatever the code says, as `path=value` with the value in NEON; it answers what init asks', + valueName: 'path=value', + repeatable: true, + normalizer: [ConfigOptions::class, 'checkDecision'], + )] + array $set = [], + #[Cli\Flag('Take the recommended answers without asking, as without a terminal')] + bool $yes = false, + #[Cli\Flag('Print the configuration instead of writing it')] + bool $dryRun = false, + #[Cli\Option( + 'The configuration of PHP CS Fixer, PHP_CodeSniffer or Laravel Pint the project runs; the one found when omitted', + valueName: 'file', + )] + ?string $formatter = null, + #[Cli\Option( + 'Output format: `console` to read and answer, or `json` for a tool or an AI agent, which takes the recommended answers', + alias: '-f', + enum: ['console', 'json'], + )] + string $format = 'console', + #[Cli\Option( + 'Number of processors the probes share; by default all of them; `1` runs them in-process', + valueName: 'n', + normalizer: [RunOptions::class, 'parsePositiveInt'], + )] + ?int $jobs = null, + ): int + { + $root = Helpers::canonicalizePath($this->context->workingDirectory); + $file = Loader::findIn($root); + if ($file !== null && $set) { + throw new UsageException( + '`--set` answers what init asks, and over `' . basename($file) . '`, which exists, it asks nothing; edit the file instead.', + $args->command, + ); + } + + try { + // over a configuration that exists init measures what has moved since, against the standard that file names + $current = $file === null ? null : Loader::loadFile($file); + } catch (ConfigurationException $e) { + throw new ConfigurationException( + '`' . basename($file) . "` exists and cannot be read, so init has nothing to compare with: {$e->getMessage()}", + previous: $e, + ); + } + + // a misspelled preset or decision before the measuring, not after it + $registry = new PluginRegistry; + foreach ($use as $preset) { + $registry->resolvePreset($preset); + } + + $answers = []; + $catalogue = new ConfigResolver($registry)->getCatalogue(); + foreach ($set as $spec) { + [$path, $value] = explode('=', $spec, 2); + $decision = $catalogue->find($path) + ?? throw new UsageException("`--set $spec` names no decision; `dresscode catalogue` lists them.", $args->command); + $answers[$path] = Neon::decode($value); + $decision->accept($answers[$path]); + } + + // the text of the format `json` is written nowhere, the document at the end saying it all + $json = $format === 'json'; + $console = $json + ? new Console(fopen('php://memory', 'w+') ?: throw new \RuntimeException, ColorDepth::None, terminal: false) + : $this->context->out; + $status = new StatusLine($console, active: $format === 'console'); + $printer = new InitPrinter($console); + // over a configuration that exists nothing is written, so nothing is asked and no answer is taken + $interactive = $current === null && $format === 'console' && !$yes && $this->context->prompt->isInteractive(); + $this->write($console, CommandContext::formatName($console) . ($current === null + ? ' looking at ' . FileSystem::platformSlashes($root) . " to write its `dresscode.neon`\n\n" + : ' comparing the code of ' . FileSystem::platformSlashes($root) . ' with its `' . basename((string) $file) . "`\n\n")); + + $status->show('Project', 'looking for the PHP files'); + $choice = $this->context->chooseFormatterConfig($root, $formatter, $interactive); + $project = ProjectFacts::detect($root, $choice->config); + // a configuration that exists says the files it checks, which is what the code is compared with it over + $scope = $current === null ? Scope::fromProject($root, $project->formatterConfig) : Scope::fromConfig($root, $current); + $this->write($console, $printer->printScope($project, $scope, $current === null ? $choice->unchosen : [])); + if ($scope->files === [] && in_array(LeftOutReason::Overridden, $scope->leftOut, true)) { + $console->clearStatus(); + $this->write($console, $printer->printNothingCompared(basename((string) $file))); + if ($json) { + $this->context->writeJson(InitPrinter::toScopeArray($root, $scope)); + } + + return 2; + + } elseif ($scope->files === []) { + throw new ConfigurationException('The project has no PHP file to measure in ' . implode(', ', $scope->listPaths()) . ', so init has nothing to make a configuration of; write `dresscode.neon` with the standard it is to keep, `use: nette`, or run init in the root of the code.'); + } + + // a probe names the plugins of the configuration by their class, which a configuration of PHP may declare itself and + // so only in this process + $plugins = array_map(fn(string|Plugin $plugin) => is_string($plugin) ? $plugin : $plugin::class, $current->plugins ?? []); + $runner = WorkerPool::buildPhpCommand() === null + || $jobs === 1 + || ($plugins !== [] && Loader::detectFormat((string) $file) === 'php') + ? new InProcessProbeRunner($root, $status->advance(...)) + : ProcessProbeRunner::create($root, $status->advance(...), $jobs); + $presets = $use ?: ($current?->use ? [...$plugins, ...$current->use] : null); + $proposal = Proposal::measure($project, $scope, $runner, $presets, $budget, $status->show(...)); + if ($proposal->standard->unsure) { + $console->clearStatus(); + $this->write($console, $printer->printUnsure($proposal)); + if ($json) { + $this->context->writeJson($printer->toArray($proposal)); + } + + return 2; + + } elseif ($current !== null) { + return $this->compare($proposal, $printer, $console, $current, (string) $file, $json); + } + + $standardQuestion = Questions::findStandardQuestion($proposal); + if ($interactive && $standardQuestion !== null) { + $console->clearStatus(); + $presets = $this->ask($printer, $standardQuestion)->measureAgainst; + $proposal = $presets === null ? $proposal : $proposal->measureAgainst($presets); + } + + foreach ($answers as $path => $value) { + $proposal->answer($path, $value); + } + + $proposal->verify(); + $rules = $this->context->confirmReadingRules($choice, $interactive, 'Read it to compare its rules with your code?') + ? $this->readFormatterRules($choice, $printer, $console) + : null; + $questions = new Questions($rules); + $asked = []; + if ($interactive) { + // the values listed are those the budget left, which the verification decides + $this->write($console, $printer->printMeasurements($proposal) . $printer->printVerification($proposal)); + $verification = $proposal->verification; + $questions->askAll($proposal, fn(array $round, bool $following) => $this->askRound($printer, $round, $following)); + if ($proposal->verification !== $verification) { + $this->write($console, "\n" . $printer->printVerification($proposal)); + } + } else { + // without a terminal the recommended answers are taken before anything is listed + $asked = [ + ...($standardQuestion === null ? [] : [$standardQuestion]), + ...$questions->askAll( + $proposal, + fn(array $round) => array_map(fn(Question $question) => $question->getRecommended(), $round), + ), + ]; + $this->write($console, $printer->printMeasurements($proposal) . $printer->printVerification($proposal)); + $this->write($console, $printer->printInstructions($asked)); + } + + $proposal->verifyWithTypes(); + $text = $proposal->toDraft()->renderChecked($root); + if ($dryRun) { + $this->write($console, "\n" . $printer->printNext($proposal, written: false) . "\n" . $text); + if ($json) { + $this->context->writeJson($printer->toArray($proposal, $asked, $text)); + } + + return $proposal->isPartial() ? 2 : 0; + } + + // another configuration may have appeared while the code was measured, which a run would read in its place + $existing = Loader::findIn($root); + if ($existing !== null || !Helpers::createFile("$root/dresscode.neon", $text)) { + $name = basename($existing ?? 'dresscode.neon'); + throw new ConfigurationException("`$name` appeared while init measured the code, so nothing was written; run init again to compare it with the code."); + } + + $suppressions = $project->formatterConfig === null + ? 0 + : new SuppressionMigration($registry->expandSuppressedName(...))->countInFiles($root, $scope->files); + $this->write($console, "\n" . $printer->printNext($proposal) . $printer->printAlso($project, $suppressions, $rules !== null)); + if ($json) { + $this->context->writeJson($printer->toArray($proposal, $asked, $text, 'dresscode.neon')); + } + + return $proposal->isPartial() ? 2 : 0; + } + + + /** Compares the code with the configuration that exists, what has moved since, and writes nothing. */ + private function compare(Proposal $proposal, InitPrinter $printer, Console $console, Config $current, string $file, bool $json): int + { + $proposal->verify(); + $shifts = $proposal->findShifts((new RunnerFactory)->resolve($current, $proposal->project->root)->resolvedConfig); + $this->write($console, $printer->printMeasurements($proposal) . $printer->printVerification($proposal) . "\n" + . $printer->printShifts($proposal, $shifts, basename($file))); + if ($json) { + $this->context->writeJson($printer->toArray($proposal, shifts: $shifts)); + } + + return $proposal->isPartial() ? 2 : 0; + } + + + private function write(Console $console, string $text): void + { + $console->write(Markup::highlightCode($console, $text)); + } + + + /** + * Asks the questions of a round one by one, numbered where there are more of them, a round following from the + * answers before said to be one. + * @param list $round + * @return list + */ + private function askRound(InitPrinter $printer, array $round, bool $following): array + { + if ($following) { + $this->context->out->write("\n" . $printer->printNextRound(count($round))); + } + + $answers = []; + foreach ($round as $i => $question) { + $answers[] = $this->ask($printer, $question, $i + 1, count($round)); + } + + return $answers; + } + + + /** Asks the question, an empty answer or the end of the input taking the recommended one. */ + private function ask(InitPrinter $printer, Question $question, int $number = 1, int $count = 1): Answer + { + $this->context->out->write("\n" . Markup::highlightCode($this->context->out, $printer->printQuestion($question, $number, $count))); + $choice = $this->context->prompt->choose('Your choice', array_map(strval(...), range(1, count($question->answers))), '1'); + return $question->answers[(int) $choice - 1] ?? $question->getRecommended(); + } + + + /** The rules of the formatter of the project, which a value of the code may contradict; null where they cannot be read. */ + private function readFormatterRules(FormatterChoice $choice, InitPrinter $printer, Console $console): ?Translation + { + $config = $choice->config ?? throw new \LogicException('No configuration of a formatter was chosen.'); + try { + return $config->translate(); + } catch (ConfigurationException $e) { + $console->write($printer->printUnreadable($config, $e->getMessage())); + return null; + } + } +} diff --git a/src/Console/Commands/MigrateCommand.php b/src/Console/Commands/MigrateCommand.php new file mode 100644 index 0000000000..c2a8b588ab --- /dev/null +++ b/src/Console/Commands/MigrateCommand.php @@ -0,0 +1,295 @@ +context->resolvePath($output); + $syntax = strtolower(pathinfo($target, PATHINFO_EXTENSION)); + if (!in_array($syntax, ['neon', 'php'], true)) { + throw new UsageException( + "The configuration `$output` is written as NEON or PHP, so its extension is `.neon` or `.php`.", + $args->command, + ); + } + + // a file of a name a run looks for collides with any configuration of the directory, a .dist template among them + $existing = in_array(basename($target), Loader::FileNames, true) + ? Loader::findIn(dirname($target)) + : (file_exists($target) ? $target : null); + if ($existing !== null && !$dryRun) { + throw new UsageException( + 'The configuration `' . basename($existing) . '` exists already; remove it, or name another file.', + $args->command, + ); + } + + $root = Helpers::canonicalizePath($this->context->workingDirectory); + if ( + Helpers::canonicalizePath(FileSystem::normalizePath(dirname($target))) + !== Helpers::canonicalizePath(FileSystem::normalizePath($root)) + ) { + // the paths it writes are relative to the project, and a configuration reads them relative to its own directory + throw new UsageException( + "The configuration `$output` would lie outside the root of the project, whose paths it names; write it into the root.", + $args->command, + ); + } + $interactive = !$json && $this->context->prompt->isInteractive(); + $choice = $this->context->chooseFormatterConfig($root, $file, $interactive); + $config = $choice->config ?? throw new UsageException( + 'The project has no configuration of PHP CS Fixer, PHP_CodeSniffer or Laravel Pint; name the file to translate.', + $args->command, + ); + if (!$choice->named) { + $this->context->writeNote('Found ' . $config->format() . ".\n" . ($choice->unchosen === [] + ? '' + : 'The project has also ' . implode(' and ', array_map(fn(FormatterCandidate $other) => $other->format(), $choice->unchosen)) + . "; the first is taken, and `dresscode migrate config ` translates another.\n")); + } + + if (!$this->context->confirmReadingRules($choice, $interactive, 'Read it?')) { + if (!$interactive) { + throw new UsageException( + "`$config->file` is PHP, which reading runs, so it is read only when named: `dresscode migrate config $config->file`.", + $args->command, + ); + } + + $this->context->writeNote("Nothing written.\n"); + return 0; + } + + $translation = $config->translate(); + // the scope as init writes it, and the decisions a rule of the formatter stands for kept where it leaves files out + $scope = Scope::fromProject($root, $config); + foreach ($scope->listExcludePaths() as $path => $why) { // what the formatter left out is left out as it was, the rest is a change + if ($why !== LeftOutReason::Foreign) { + $translation->warn("`$path` is excluded, $why->value, though {$config->formatter->value} checked it; drop it from `excludePaths` to check it still."); + } + } + + $draft = $translation->toDraft(); + $catalogue = new ConfigResolver(new PluginRegistry)->getCatalogue(); + $scope->writeInto($draft, $config, fn(string $path) => $catalogue->find($path)?->takesKeep() ?? false); + $text = $draft->renderChecked($root, $syntax); + if (!$dryRun && !Helpers::createFile($target, $text)) { + throw new UsageException("The configuration `$output` appeared meanwhile; remove it, or name another file.", $args->command); + } + + $untranslated = $translation->findUntranslated(); + $suppressions = new SuppressionMigration(new PluginRegistry()->expandSuppressedName(...))->countInFiles($root, $scope->files); + // a translation not carried over whole is written all the same, but the run says it is not done + $exit = $translation->warnings ? 1 : 0; + if ($json) { + $this->context->writeJson([ + 'version' => self::Version, + 'root' => $root, + 'source' => $config->file, + 'formatter' => $config->formatter->value, + 'configuration' => $text, + 'written' => $dryRun ? null : $output, + 'rules' => $translation->ruleCount, + 'decisions' => count($translation->getPaths()), + 'presets' => $translation->presets, + 'warnings' => $translation->warnings, + 'untranslated' => array_keys($untranslated), + 'suppressions' => $suppressions, + ]); + return $exit; + } + + if ($dryRun) { + $this->context->out->write($text); + $this->context->writeNote("Nothing written, as `--dry-run` asks.\n"); + } else { + $this->context->writeNote("Written `$output`.\n"); + } + + $this->context->writeNote(sprintf( + "\nRead %s; set %s and %s, the rules translated; `dresscode init` measures the code instead.\n", + Markup::formatCount($translation->ruleCount, 'rule'), + Markup::formatCount(count($translation->getPaths()), 'decision'), + Markup::formatCount(count($translation->presets), 'preset'), + )); + foreach ($translation->warnings as $warning) { + $this->context->writeNote(" $warning\n"); + } + + if ($untranslated) { + $this->context->writeNote(sprintf( + " %s %s not translated; set %s with `%s`.\n", + ucfirst(implode(' and ', $untranslated)), + count($untranslated) > 1 ? 'are' : 'is', + count($untranslated) > 1 ? 'them' : 'it', + implode('` and `', array_keys($untranslated)), + )); + } + + if ($suppressions) { + $this->context->writeNote("\n" . Markup::formatSuppressionsHint($suppressions) . ".\n"); + } + + return $exit; + } + + + /** + * @param list $paths + */ + #[Cli\Command('Rewrite the suppression comments of PHP_CodeSniffer to the DressCode form')] + public function suppressions( + ParseResult $args, + #[Cli\Argument('Files or directories; the configured paths when omitted', optional: true, repeatable: true)] + array $paths, + ConfigOptions $configuration, + #[Cli\Flag('Show the rewrites as a unified diff instead of writing them')] + bool $dryRun = false, + #[Cli\Option('Output format: `console`, or `json` for a tool or an AI agent', alias: '-f', enum: ['console', 'json'])] + string $format = 'console', + ): int + { + // the comments need the files and the names of the decisions, not the rules, which may not run yet + [$factory, $resolution] = $this->context->resolveProject($args, $configuration); + $config = $resolution->config; + $finder = $factory->createFileFinder($resolution); + $named = array_map($this->context->resolvePath(...), $paths); + $paths = $named ? $finder->narrowPaths($named, $config->paths) : $config->paths; + if (!$paths) { + throw new UsageException('No paths given and none configured.', $args->command); + } + + $migration = new SuppressionMigration($factory->registry->expandSuppressedName(...)); + $parser = new Parser; + $count = $files = 0; + $unknown = $widened = $skipped = $diffs = []; + foreach ($finder->findFiles($paths) as $path) { + $absolute = $finder->toAbsolute($path); + $code = @file_get_contents($absolute); // @ is escalated to exception + if ($code === false) { + throw new \RuntimeException("Cannot read file `$path`."); + } + + try { + $file = $parser->parse($code); + } catch (ParseException $e) { + $skipped[$path] = $e->getMessage(); + continue; + } + + $rewrite = $migration->migrate($file); + if ($rewrite->count > 0) { + if ($dryRun) { + $diffs[$path] = Diff::unified($code, Printer::print($file), $path); + } elseif (@file_get_contents($absolute) !== $code) { // @ a file deleted meanwhile changed too + $skipped[$path] = 'it changed while it was being migrated'; + continue; + } elseif (!Helpers::writeFile($absolute, Printer::print($file))) { + throw new \RuntimeException("Cannot write file `$path`."); + } + + $count += $rewrite->count; + $files++; + } + + // what is told is what the files hold now, a file skipped saying nothing + foreach ($rewrite->unknownNames as $name => $lines) { + $unknown[$name] = [...$unknown[$name] ?? [], ...array_map(fn(int $line) => "$path:$line", $lines)]; + } + + array_push($widened, ...array_map(fn(int $line) => "$path:$line", $rewrite->widened)); + } + + // a comment the migration could not carry over is a migration not done, a scope grown one to review + $exit = $unknown !== [] || $skipped !== [] ? 2 : ($widened !== [] ? 1 : 0); + if ($format === 'json') { + $this->context->writeJson([ + 'version' => self::Version, + 'root' => $resolution->root, + 'migrated' => $count, + 'files' => $files, + 'written' => !$dryRun, + 'diffs' => (object) $diffs, + 'unknown' => (object) $unknown, + 'widened' => $widened, + 'skipped' => (object) $skipped, + ]); + return $exit; + } + + foreach ($diffs as $diff) { + $this->context->out->write(Markup::highlightDiff($this->context->out, $diff)); + } + + $this->context->write( + ($dryRun ? 'Would migrate ' : 'Migrated ') + . Markup::formatCount($count, 'suppression') . ' in ' . Markup::formatCount($files, 'file') + . ($dryRun ? ', writing nothing' : '') . ".\n", + ); + foreach ($unknown as $name => $places) { + $this->context->write(Markup::formatLabel($this->context->out, 'Unknown') . "`$name`, kept as written, as no decision answers to it: " . implode(', ', $places) . "\n"); + } + + if ($widened) { + $this->context->write(Markup::formatLabel($this->context->out, 'Widened') . implode(', ', $widened) . ': a `dresscode:ignore` on a line of its own covers the whole statement below it, not just the next line' . "\n"); + } + + foreach ($skipped as $path => $why) { + $this->context->write(Markup::formatLabel($this->context->out, 'Skipped') . "$path, its comments left as they are: $why\n"); + } + + return $exit; + } +} diff --git a/src/Console/Commands/PlanOptions.php b/src/Console/Commands/PlanOptions.php new file mode 100644 index 0000000000..e39ca844a9 --- /dev/null +++ b/src/Console/Commands/PlanOptions.php @@ -0,0 +1,58 @@ +testCommand ?? $this->quickTestCommand ?? $this->library) !== null; + } + + + /** Why the options given do not go together, as the command line writes them; null where they do. */ + public function findMisuse(): ?string + { + $noReport = array_find( + [$this->testCommand, $this->quickTestCommand], + fn(?string $command) => $command !== null && !TestResults::hasJunitPlaceholder($command), + ); + return $noReport === null + ? null + : "A command of the tests writes its JUnit report where `{junit}` stands, and `$noReport` has none."; + } +} diff --git a/src/Console/Commands/PlanRunOptions.php b/src/Console/Commands/PlanRunOptions.php new file mode 100644 index 0000000000..e407fecc08 --- /dev/null +++ b/src/Console/Commands/PlanRunOptions.php @@ -0,0 +1,30 @@ + $changes the operations of Composer, as `- Upgrading nette/utils (v3.2.10 => v4.0.11)` */ + public function approve(DependencyStep $step, array $changes): bool + { + $console = $this->console; + $console->clearStatus(); + $console->writeLine(Markup::formatLabel($console, 'Composer') . "$step->title, " . ($changes === [] ? 'which changes no installed package' : 'which changes:')); + foreach ($changes as $change) { + $console->writeLine(" $change"); + } + + // a raise installs the lowest versions it allows, which may be older than those the lock holds today + $downgrades = count(preg_grep('~^- Downgrading ~', $changes) ?: []); + if ($downgrades > 0) { + $console->writeLine(Markup::formatLabel($console, 'Downgrades') . Markup::formatCount($downgrades, 'package') . ' the lock holds newer, installed at the lowest versions the constraints allow, which the code is written for; the last step of the plan updates them'); + } + + $question = $step instanceof UpdateStep ? 'Update the packages?' : 'Change `composer.json` and update the packages?'; + if (!$this->yes && $this->context->prompt->choose($question, ['y', 'n'], 'y') !== 'y') { + return false; + } + + $console->setStatus(Markup::formatLabel($console, 'Upgrading') . "Composer, $step->title\u{2026}"); + return true; + } +} diff --git a/src/Console/Commands/RunHandoff.php b/src/Console/Commands/RunHandoff.php new file mode 100644 index 0000000000..46ac819f0b --- /dev/null +++ b/src/Console/Commands/RunHandoff.php @@ -0,0 +1,91 @@ +file ?? throw new \LogicException('The user started the run, so there is no run to hand back to.'); + Helpers::replaceFile($file, json_encode($outcome->toArray(), JSON_THROW_ON_ERROR)); + } + + + /** + * Goes on after the raise in a new process as often as the plan has steps of Composer, each process handing back + * where it stopped at the next raise; null where this process starts none, as an embedded one. + * @param list $rerun the command line of the run + */ + public static function goOn(CommandContext $context, RunOutcome $outcome, Plan $plan, array $rerun): ?int + { + $file = sys_get_temp_dir() . '/dresscode-handoff-' . bin2hex(random_bytes(8)) . '.json'; + $raises = count(array_filter($plan->findSteps(), fn(Step $step) => $step instanceof DependencyStep)); + try { + while ($raises-- > 0) { + Helpers::replaceFile($file, json_encode($outcome->toArray(), JSON_THROW_ON_ERROR)); + $exit = $context->rerun($rerun, [self::Variable => $file]); + if ($exit === null || !is_file($file)) { // the run cannot go on here, or went on to its end + return $exit; + } + + $outcome = self::read($file); + } + } finally { + @unlink($file); // @ the last run took it + } + + throw new \LogicException('The plan raised more often than it has steps of Composer.'); + } + + + private static function read(string $file): RunOutcome + { + return RunOutcome::fromArray((array) json_decode(FileSystem::read($file), associative: true, flags: JSON_THROW_ON_ERROR)); + } +} diff --git a/src/Console/Commands/RunOptions.php b/src/Console/Commands/RunOptions.php index a4174b174f..74b4e92c4e 100644 --- a/src/Console/Commands/RunOptions.php +++ b/src/Console/Commands/RunOptions.php @@ -33,7 +33,7 @@ enum: ['console', 'agent', 'lines', 'github', 'json', 'checkstyle'], #[Cli\Option( 'Number of worker processes; by default the number of processors, at most one per four files; `1` runs in-process', valueName: 'n', - normalizer: [self::class, 'parseJobs'], + normalizer: [self::class, 'parsePositiveInt'], )] public ?int $jobs = null, #[Cli\Flag('Treat a rule breaking its contract as an error, not a warning')] @@ -41,11 +41,14 @@ enum: ['console', 'agent', 'lines', 'github', 'json', 'checkstyle'], /** the address of the parent; a worker started by WorkerPool */ #[Cli\Option(hidden: true)] public ?string $worker = null, + /** a file to write where the time of the run went, see Profiler */ + #[Cli\Option(hidden: true)] + public ?string $profile = null, ) { } - public static function parseJobs(string $value): int + public static function parsePositiveInt(string $value): int { return Normalizers::int($value, min: 1); } diff --git a/src/Console/Commands/TodayOptions.php b/src/Console/Commands/TodayOptions.php new file mode 100644 index 0000000000..efa0baffaa --- /dev/null +++ b/src/Console/Commands/TodayOptions.php @@ -0,0 +1,82 @@ + */ + #[Cli\Option('Files or directories; the configured paths when omitted', valueName: 'path', repeatable: true)] + public array $paths = [], + #[Cli\Option('Output format, any `fix` writes', alias: '-f', enum: ['console', 'json', 'agent', 'lines', 'github', 'checkstyle'])] + public ?string $format = null, + #[Cli\Flag('Also write what newer PHP offers, the set `modernizations`')] + public ?bool $modernize = null, + #[Cli\Flag('Ask about every risky fix left, one at a time with its diff, and make those accepted; needs an interactive terminal')] + public ?bool $askRisky = null, + #[Cli\Flag('Install the upgrading data the libraries of the project need without asking, outside a terminal too')] + public ?bool $yes = null, + /** @var list */ + #[Cli\Option( + 'Also make the fixes that may change what the code does: all, or with `=name` those of a decision, rule, preset or package. They are reported either way', + valueName: 'name', + valueOptional: true, + repeatable: true, + )] + public array $fixRisky = [], + /** @var list */ + #[Cli\Option( + 'Run only these: a decision, section, rule or preset, or a package of upgrading data with the rules reading it', + valueName: 'name', + repeatable: true, + )] + public array $only = [], + #[Cli\Option( + 'Exit with `1` when more than `n` warnings are left, as in CI', + valueName: 'n', + normalizer: [CheckOptions::class, 'parseMaxWarnings'], + )] + public ?int $maxWarnings = null, + #[Cli\Option( + 'Number of worker processes; by default the number of processors, at most one per four files; `1` runs in-process', + valueName: 'n', + normalizer: [RunOptions::class, 'parsePositiveInt'], + )] + public ?int $jobs = null, + #[Cli\Flag('Process every file, even one whose content is known to be clean')] + public ?bool $noCache = null, + /** the address of the parent; a worker started by WorkerPool */ + #[Cli\Option(hidden: true)] + public ?string $worker = null, + /** a file to write where the time of the run went, see Profiler */ + #[Cli\Option(hidden: true)] + public ?string $profile = null, + ) { + } + + + /** The run over the files it makes. */ + public function toRunOptions(): RunOptions + { + return new RunOptions( + $this->paths, + $this->format, + noCache: $this->noCache, + jobs: $this->jobs, + worker: $this->worker, + profile: $this->profile, + ); + } +} diff --git a/src/Console/Commands/TodayUpgrade.php b/src/Console/Commands/TodayUpgrade.php new file mode 100644 index 0000000000..f71297d71a --- /dev/null +++ b/src/Console/Commands/TodayUpgrade.php @@ -0,0 +1,138 @@ + those of `--only`, which the parent gives a worker too */ + private array $only, + ) { + } + + + /** + * The project of the run: its configuration, or the scope `init` would write where it has none, with the sets of the + * upgrade laid over the presets it uses and beneath its own decisions and overrides, and the plugins of the packages + * of upgrading data with the types of the code and the names resolved for certain over it all, narrowed by `--only`, + * or where it has a configuration to the decisions its style keeps and to the packages whose data reach the project. + * @return array{RunnerFactory, ResolvedProject, ?string} + * @throws ConfigurationException + */ + public function resolve(ParseResult $args, ConfigOptions $configuration): array + { + ['config' => $config, 'root' => $root, 'file' => $file, 'commandLine' => $commandLine] + = $this->context->loadConfig($args, $configuration, new Config(paths: ['.'])); + $project = Project::fromConfig($config, $root, $file, $commandLine); + $config = $project->config; + $discovered = $project->discoverPackages(); + $sets = $this->modernize ? ['deprecations', 'modernizations'] : ['deprecations']; + $names = $project->resolveNames(); + $layer = $project->createLayer($discovered->findDataPlugins(), types: true, declarations: $names->declarations); + $only = $this->only; + if ($file !== null && !$only) { + $only = [...$project->measureSatisfiedRequirements(), ...$sets, ...$discovered->findReachingPackages()]; + } + + [$factory, $resolution] = new UpgradeCheck($layer, $only ?: null, $sets)->resolve($config, $root); + return [$factory, $names->warning === null ? $resolution : $resolution->withWarning($names->warning), $file]; + } + + + /** + * What a worker gets on its command line beyond that of `check`: the sets, and what the parent narrowed the run to, + * unless `--only` did, which goes over as it is. + * @return list + */ + public function getWorkerOptions(ResolvedProject $resolution): array + { + return [ + ...$this->modernize ? ['--modernize'] : [], + ...$this->only ? [] : array_map(fn(string $name) => "--only=$name", $resolution->only ?? []), + ]; + } + + + /** + * What the run upgrades by: the packages of upgrading data, those installed newer than the lowest version the + * project allows, whose declarations then say nothing of what that one deprecates, and those it allows in any version, + * whose data are reported and never written. + */ + public function writePreface(ResolvedProject $resolution): void + { + $out = $this->context->out; + $project = $resolution->projectPackages; + $packages = array_diff( + array_unique(array_map(fn(array $data) => $data[0]->package, $resolution->upgradingData)), + [PackageDiscovery::Php], + ); + $out->writeLine( + Markup::formatLabel($out, 'Upgrading') . (implode(', ', $packages) ?: 'PHP alone, no installed library has upgrading data'), + ); + + $newer = []; + foreach ($project->findNewerInstalled() as $package => [$installed, $lowest]) { + $newer[] = "$package " . Versions::formatVersion($installed) . " installed, $lowest required"; + } + + if ($newer !== []) { + $out->writeLine( + Markup::formatLabel($out, 'Lowest') + . implode(', ', $newer) + . '; `composer update --prefer-lowest` lets the upgrade see what the lowest versions deprecate', + ); + } + + $unbounded = array_filter($packages, $project->isUnbounded(...)); + if ($unbounded !== []) { + $out->writeLine( + Markup::formatLabel($out, 'Reported') + . implode(', ', $unbounded) + . ': not required with a lower bound, so the upgrading data are reported, never written', + ); + } + } + + + /** + * The files the run goes through, without those a generator wrote. + * @param list $files relative to the root, or absolute when outside it + * @return list + */ + public function leaveOutGenerated(array $files, string $root, bool $preface): array + { + $found = UpgradeFiles::fromScope($root, $files); + if ($preface && $found->generated !== []) { + $out = $this->context->out; + $out->writeLine(Markup::formatLabel($out, 'Left out') . Markup::formatCount(count($found->generated), 'generated file')); + } + + return $found->files; + } +} diff --git a/src/Console/Commands/UpgradeCommand.php b/src/Console/Commands/UpgradeCommand.php new file mode 100644 index 0000000000..0f025c6082 --- /dev/null +++ b/src/Console/Commands/UpgradeCommand.php @@ -0,0 +1,701 @@ +executor = new RunExecutor($context); + } + + + #[Cli\Command('Estimate the changes of the code an upgrade to a newer PHP or library needs, changing nothing')] + public function estimate( + ParseResult $args, + #[Cli\Argument('`php` or a package; PHP and every library with upgrading data when omitted', optional: true)] + ?string $target, + #[Cli\Argument('The version to upgrade to; for PHP the newest its upgrading data know when omitted', optional: true)] + ?string $version, + EstimateOptions $options, + ConfigOptions $configuration, + ): int + { + if ($options->worker !== null && $options->checkFile !== null) { + return $this->runEstimateWorker($args, $options->worker, $options->checkFile, $configuration); + } + + $project = $this->loadProject($args, $configuration); + $console = $this->findConsole($options->format); + $types = $this->decideTypes($options->types, $this->mayAsk($options->format), $project->config); + $estimate = $this->measure($args, $project, $target, $version, $types, $options, $configuration, $console); + $printer = new EstimatePrinter($estimate); + if ($console === null) { + $this->writeJson($project, $printer->toArray()); + } else { + $console->write(Markup::highlightCode($console, $printer->print($console))); + } + + return $estimate->isComplete() ? 0 : 2; + } + + + #[Cli\Command('Order the estimate into the steps of an upgrade, changing nothing; `--save` keeps the plan')] + public function plan( + ParseResult $args, + #[Cli\Argument('`php` or a package; PHP and every library with upgrading data when omitted', optional: true)] + ?string $target, + #[Cli\Argument('The version to upgrade to; for PHP the newest its upgrading data know when omitted', optional: true)] + ?string $version, + #[Cli\Flag('Save the plan into `dresscode-upgrade.neon`, which the team may edit and `upgrade run` carries out')] + ?bool $save, + #[Cli\Flag('Drop what a run interrupted in a step kept of its files, which saving the plan forgets')] + ?bool $discard, + EstimateOptions $options, + PlanOptions $planning, + ConfigOptions $configuration, + ): int + { + if (($misuse = $planning->findMisuse()) !== null) { + throw new UsageException($misuse, $args->command); + } + + $project = $this->loadProject($args, $configuration); + $console = $this->findConsole($options->format); + $types = $this->decideTypes($options->types, $this->mayAsk($options->format), $project->config); + [$estimate, $plan, $mode] = $this->makePlan( + $args, $project, $target, $version, $types, $options, $planning, $configuration, $console, + ); + // the plan comes after the estimate it orders, which says what it cannot see and what it is not sure of + $estimatePrinter = new EstimatePrinter($estimate); + $planPrinter = new PlanPrinter($plan, $mode); + $console?->write(Markup::highlightCode($console, $estimatePrinter->print($console) . "\n" . $planPrinter->print($console))); + if ($save) { + $this->savePlan($args, $plan, $project, (bool) $discard, $console); + } + + if ($console === null) { + $this->writeJson($project, [...$planPrinter->toArray(), 'estimate' => $estimatePrinter->toArray()], saved: (bool) $save); + } + + if ($plan->steps !== []) { + $console?->writeLine(Markup::formatLabel($console, 'Next') . ($save + ? '`dresscode upgrade run` carries out the plan saved, step by step' + : '`--save` keeps the plan in `' + . PlanFile::Name + . '` for the team to review, which `dresscode upgrade run` then carries out')); + } + + return $estimate->isComplete() ? 0 : 2; + } + + + #[Cli\Command('Carry out the saved plan, or make one for the target first; stops where Composer or you are needed')] + public function run( + ParseResult $args, + #[Cli\Argument('`php` or a package, to make a plan for; the saved plan when omitted', optional: true)] + ?string $target, + #[Cli\Argument('The version to upgrade to; for PHP the newest its upgrading data know when omitted', optional: true)] + ?string $version, + EstimateOptions $options, + PlanOptions $planning, + PlanRunOptions $running, + ConfigOptions $configuration, + ): int + { + if ($target === null && ($planning->isGiven() || $options->paths !== [] || $options->offline || $options->jobs !== null)) { + throw new UsageException( + '`--paths`, `--offline`, `--jobs`, the commands of the tests and `--library` make a plan, which a target does; without one `upgrade run` carries out the saved plan as it is.', + $args->command, + ); + } elseif (($misuse = $planning->findMisuse()) !== null) { + throw new UsageException($misuse, $args->command); + } + + $project = $this->loadProject($args, $configuration); + if ($target !== null && PlanFile::exists($project->root)) { + throw new UsageException( + 'The project has a saved plan, `' + . PlanFile::Name + . '`, which `upgrade run` carries out; `upgrade plan --save` replaces it.', + $args->command, + ); + } + + $console = $this->findConsole($options->format); + $mayAsk = $this->mayAsk($options->format); + $types = $this->decideTypes($options->types, $mayAsk, $project->config); + $runner = $this->createPlanRunner($project, $types, (bool) $running->commit, $console); + $handoff = RunHandoff::take(); + if ($target === null) { + $plan = PlanFile::read($project->root); + if ($console !== null && $handoff->resumed === null) { + self::writeHeader($console, $project); + } + } else { + [$estimate, $plan, $mode] = $this->makePlan( + $args, $project, $target, $version, $types, $options, $planning, $configuration, $console, + ); + $console?->write(Markup::highlightCode( + $console, + new EstimatePrinter($estimate)->print($console) . "\n" . new PlanPrinter($plan, $mode)->print($console), + )); + $this->savePlan($args, $plan, $project, (bool) $running->discard, $console); + } + + // the run after a raise goes on in a process of its own with what this one decided + $rerun = [ + 'upgrade', + 'run', + $types ? '--types' : '--no-types', + ...$options->format === null ? [] : ["--format=$options->format"], + ...$running->yes ? ['--yes'] : [], + ...$running->commit ? ['--commit'] : [], + ...$args->get('--no-color') ? ['--no-color'] : [], + ...$configuration->toArguments(), + ]; + return $this->runPlan($plan, $runner, $running, $mayAsk, $console, $rerun, $handoff); + } + + + #[Cli\Command('Undo an unfinished upgrade: restore the files of an interrupted step and the `composer.json` and lock a raise changed')] + public function abort( + ParseResult $args, + ConfigOptions $configuration, + #[Cli\Flag('Say what it would put back and leave, changing nothing')] + bool $dryRun = false, + ): int + { + $project = $this->loadProject($args, $configuration); + $console = $this->context->out; + self::writeHeader($console, $project); + $restoration = new RunState($project)->restore( + $project, + Composer::forProject($project->root), + $dryRun, + fn(string $what) => $console->setStatus(Markup::formatLabel($console, 'Upgrading') . "$what\u{2026}"), + ); + $console->clearStatus(); + [$restored, $left, $installFailure] = [$restoration->restored, $restoration->left, $restoration->installFailure]; + if ($restored !== [] || !$restoration->installs) { + $console->writeLine( + Markup::formatLabel($console, $dryRun ? 'Restorable' : 'Restored') + . ($restored === [] ? 'nothing, as no run keeps anything to go back' : implode(', ', $restored)), + ); + } + + if ($restoration->installs && $installFailure === null) { + $console->writeLine(Markup::formatLabel($console, $dryRun ? 'Installs' : 'Installed') . 'the packages of the lock, by `composer install`'); + } + + foreach ($left as $path => $why) { + $console->writeLine(Markup::formatLabel($console, 'Left') . "$path: $why"); + } + + if ($installFailure !== null) { + $console->writeLine(Markup::formatLabel($console, 'Failed') . "`composer install`: $installFailure"); + } + + return $dryRun || ($left === [] && $installFailure === null) ? 0 : 1; + } + + + #[Cli\Command('List the manual tasks the saved plan leaves in the code as it stands, changing nothing')] + public function tasks( + ParseResult $args, + #[Cli\Flag( + 'Look with the types of the code from PHPStan, without asking; `--no-types` without them', + negatable: true, + )] + ?bool $types, + #[Cli\Option( + 'Output format: `console`, or `json` for a tool or an AI agent, which never asks', + alias: '-f', + enum: ['console', 'json'], + )] + ?string $format, + ConfigOptions $configuration, + ): int + { + $project = $this->loadProject($args, $configuration); + $console = $this->findConsole($format); + $plan = PlanFile::read($project->root); + $runner = $this->createPlanRunner($project, $this->decideTypes($types, $this->mayAsk($format), $project->config), false, $console); + if ($console !== null) { + self::writeHeader($console, $project); + } + + try { + [$tasks, $unprocessed] = $runner->findTasks($plan); + $postponed = Postponement::findIn($project, $plan->paths); + } finally { + ($console ?? $this->context->err)->clearStatus(); + } + + $printer = new TasksPrinter($tasks, $postponed, $unprocessed); + $left = $tasks !== [] || $postponed !== []; + if ($console === null) { + $this->writeJson($project, ['version' => TasksPrinter::Version, ...$printer->toArray()], saved: true); + } else { + $console->write(Markup::highlightCode($console, $printer->print($console))); + $console->writeLine(match (true) { + $unprocessed !== [] => $console->color('white/red', 'FAILED the plan leaves ' + . ($left ? 'manual tasks' : 'no manual task') . ' in the files the fixes could process'), + $left => $console->color('white/red', 'FOUND the plan leaves manual tasks'), + default => $console->color('white/green', 'OK the plan leaves no manual task'), + }); + } + + return match (true) { + $unprocessed !== [] => 2, + $left => 1, + default => 0, + }; + } + + + #[Cli\Command('Say where the saved plan stands and what a run keeps to go back, running no fix')] + public function status( + ParseResult $args, + #[Cli\Option('Output format: `console`, or `json` for a tool or an AI agent', alias: '-f', enum: ['console', 'json'])] + ?string $format, + ConfigOptions $configuration, + ): int + { + $project = $this->loadProject($args, $configuration); + $console = $this->findConsole($format); + $plan = PlanFile::read($project->root); + $printer = new StatusPrinter($plan, PlanStatus::read($plan, $project)); + if ($console === null) { + $this->writeJson($project, $printer->toArray(), saved: true); + } else { + self::writeHeader($console, $project); + $console->write(Markup::highlightCode($console, $printer->print($console))); + } + + return 0; + } + + + #[Cli\Command('Rewrite the old API for the versions `composer.json` requires now, keeping the style of the project')] + public function today(ParseResult $args, TodayOptions $options, ConfigOptions $configuration): int + { + $run = $options->toRunOptions(); + // a worker runs with what its parent installed, and what is installed, plugins among it, loads in a new process + if ( + $run->worker === null + && new DataPackageOffer($this->context)->install($args, $configuration, (bool) $options->yes, $this->mayAsk($options->format)) + ) { + $exit = $this->context->rerun(array_values(array_filter(array_slice($_SERVER['argv'], 1), is_string(...)))); + if ($exit === null) { + $this->context->err->writeLine('The upgrading data are installed; run the command again, which loads them.'); + } + + return $exit ?? 4; + } + + return $this->executor->run( + $args, + $configuration, + $run, + new CheckOptions(maxWarnings: $options->maxWarnings, fixRisky: $options->fixRisky, only: $options->only), + fix: true, + askRisky: (bool) $options->askRisky, + upgrade: new TodayUpgrade($this->context, (bool) $options->modernize, $options->only), + ); + } + + + /** Measures what upgrading to the targets changes, changing nothing. */ + private function measure( + ParseResult $args, + Project $project, + ?string $target, + ?string $version, + bool $types, + EstimateOptions $options, + ConfigOptions $configuration, + ?Console $console, + bool $library = false, + ): Estimate + { + $estimator = new Estimator( + $project, + $types, + (bool) $options->offline, + createWorkers: fn(UpgradeCheck $check, int $files) => $this->createEstimateWorkers( + $configuration, + $check, + $options->jobs ?? WorkerPool::countDefaultJobs($files), + $project->getCacheDir(), + ), + onProgress: $console === null + ? null + : fn(string $what) => $console->setStatus(Markup::formatLabel($console, 'Measuring') . "$what\u{2026}"), + library: $library, + ); + $targets = $this->resolveTargets($args, $target, $version, $estimator); + if ($console !== null) { + self::writeHeader($console, $project); + } + + try { + return $estimator->estimate($targets, array_map($this->context->resolvePath(...), $options->paths)); + } finally { + $console?->clearStatus(); + } + } + + + /** + * Measures what upgrading to the targets changes and orders it into the steps of an upgrade, of a library or of an + * application, which it says with why. + * @return array{Estimate, Plan, string} + */ + private function makePlan( + ParseResult $args, + Project $project, + ?string $target, + ?string $version, + bool $types, + EstimateOptions $options, + PlanOptions $planning, + ConfigOptions $configuration, + ?Console $console, + ): array + { + // a library moves no installed package, which the estimate would otherwise ask Composer about + [$library, $why] = $planning->library === null + ? $this->decideLibrary($this->mayAsk($options->format), $project) + : [$planning->library, 'as `--' . ($planning->library ? '' : 'no-') . 'library` says']; + $estimate = $this->measure($args, $project, $target, $version, $types, $options, $configuration, $console, $library); + if ($estimate->targets === []) { // a project on PHP 7 is offered none + throw new UsageException( + 'There is no version to plan an upgrade to; name one, as `php 8.4` or `vendor/name 4.0`.', + $args->command, + ); + } + + $plan = new PlanBuilder(ProjectPackages::read($project->root)) + ->build($estimate, $planning->testCommand, $planning->quickTestCommand); + return [$estimate, $plan, ($library ? 'library, ' : 'application, ') . $why]; + } + + + /** + * Saves the plan, which forgets what the runs of the one before kept, refusing to forget the files of a step a run left + * unfinished unless told to discard them; a plan of no step is no file, which nothing would run. + * @throws UsageException + */ + private function savePlan(ParseResult $args, Plan $plan, Project $project, bool $discard, ?Console $console): void + { + if ($plan->steps === []) { + $console?->writeLine(Markup::formatLabel($console, 'Saved') . 'nothing, as the upgrade has no step to make'); + return; + } + + $state = new RunState($project); + $state->runExclusively(function () use ($state, $discard, $args, $plan, $project) { + $interrupted = $state->snapshots->findInterrupted(); + if ($interrupted !== [] && !$discard) { + $files = array_merge(...array_values($interrupted)); + throw new UsageException( + 'Step ' . array_key_first($interrupted) . ' of a run did not finish' + . ($files === [] ? '' : ' and may have rewritten ' . implode(', ', $files)) + . '; saving a plan forgets what it kept, so put the files back with `dresscode upgrade abort` first, or pass `--discard`.', + $args->command, + ); + } + + PlanFile::write($plan, $project->root); + $state->remove(); + }); + $console?->writeLine(Markup::formatLabel($console, 'Saved') . PlanFile::Name); + } + + + /** + * The runner of the plan, telling its progress on the status line of the console, which data have none. + * @throws ConfigurationException for a project without a configuration file + */ + private function createPlanRunner(Project $project, bool $types, bool $commit, ?Console $console): PlanRunner + { + $console ??= $this->context->err; + return new PlanRunner( + $project, + $types, + $commit, + fn(string $what) => $console->setStatus(Markup::formatLabel($console, 'Upgrading') . "$what\u{2026}"), + ); + } + + + /** + * Runs the plan over the working tree and says how far it got, as text on the console or, without one, as a document + * of data whose warnings go to standard error, with the exit code of the outcome. After a raise it goes on in a new + * process, `$rerun` its command line, which tells how far the whole run got; a run going on after a raise + * says nothing of what the run before it said, and hands back where it stopped instead. + * @param list $rerun + */ + private function runPlan( + Plan $plan, + PlanRunner $runner, + PlanRunOptions $running, + bool $mayAsk, + ?Console $output, + array $rerun, + RunHandoff $handoff, + ): int + { + $console = $output ?? $this->context->err; + if ($handoff->resumed === null) { // what the run before the raise said already + $this->warnBeforeRun($plan, $runner, (bool) $running->commit, $console); + } + + // without a terminal only `--yes` agrees, so without it Composer is not even asked what a raise changes + $approve = $running->yes || $mayAsk ? new RaiseApproval($this->context, $console, (bool) $running->yes)->approve(...) : null; + try { + $outcome = $runner->run($plan, $running->step, $approve, (bool) $running->discard, $handoff->resumed); + } finally { + $console->clearStatus(); + } + + if ($outcome->stop === RunStop::Raised && $handoff->resumed !== null) { + $handoff->handBack($outcome); + return 0; + } elseif ($outcome->stop === RunStop::Raised && ($exit = RunHandoff::goOn($this->context, $outcome, $plan, $rerun)) !== null) { + return $exit; + } + + $printer = new RunOutcomePrinter($plan, $outcome, $running->step); + if ($output !== null) { + $output->write(Markup::highlightCode($output, $printer->print($output))); + } else { + $this->writeJson($runner->project, [ + 'version' => RunOutcomePrinter::Version, + 'plan' => new PlanPrinter($plan)->toArray(), + 'outcome' => $printer->toArray(), + ], saved: true); + } + + return $outcome->getExitCode(); + } + + + private function warnBeforeRun(Plan $plan, PlanRunner $runner, bool $commit, Console $console): void + { + if (!$plan->isMadeWith(Provenance::current(ProjectPackages::read($runner->project->root)))) { + $console->writeLine(Markup::formatLabel($console, 'Warning') . 'The plan was made with other versions of DressCode or of the upgrading data, so a step may fix more or less than it did then.'); + } + + if ($plan->unestimated !== [] || $plan->missingData !== []) { + $console->writeLine(Markup::formatLabel($console, 'Warning') . 'The estimate of the plan ' + . implode(' and ', array_filter([ + $plan->unestimated === [] ? '' : 'could not process ' . implode(', ', $plan->unestimated), + $plan->missingData === [] ? '' : 'lacked the upgrading data of ' . implode(', ', array_keys($plan->missingData)), + ])) + . '; the run checks the whole code against the targets at its end.'); + } + + if (!$runner->state->durable) { + $console->writeLine(Markup::formatLabel($console, 'Warning') . 'Git finds no repository of the project, so what a run keeps to go back lies in the cache directory, which clearing the cache loses.'); + } + + if (!$commit && $runner->hasOwnChanges()) { + $console->writeLine(Markup::formatLabel($console, 'Warning') . 'The working tree has changes of its own, which the changes of the upgrade mix with.'); + } + } + + + /** @param array $data */ + private function writeJson(Project $project, array $data, bool $saved = false): void + { + $data = [ + 'version' => $data['version'], + 'root' => $project->root, + ...$saved ? ['planIdentity' => PlanFile::identify($project->root)] : [], + ...$data, + ]; + $this->context->writeJson($data); + } + + + /** The name of the tool and the configuration the command measures or runs with. */ + private static function writeHeader(Console $console, Project $project): void + { + $console->writeLine(CommandContext::formatName($console)); + $console->writeLine(Markup::formatLabel($console, 'Config') . ($project->configFile === null + ? 'none, the scope `init` would write: ' . implode(', ', $project->config->paths) + : FileSystem::platformSlashes($project->configFile))); + } + + + /** The console the format writes text to; null for data. */ + private function findConsole(?string $format): ?Console + { + return ($format ?? 'console') === 'console' ? $this->context->out : null; + } + + + /** Whether the command may ask the user: in a terminal, writing text for a human rather than data for a tool. */ + private function mayAsk(?string $format): bool + { + return ($format ?? 'console') === 'console' && $this->context->prompt->isInteractive(); + } + + + /** + * The project of the command: its configuration, or the scope `init` would write where it has no file of its own. + * @throws ConfigurationException for a directory that is no project at all + */ + private function loadProject(ParseResult $args, ConfigOptions $configuration): Project + { + ['config' => $config, 'root' => $root, 'file' => $file, 'commandLine' => $commandLine] + = $this->context->loadConfig($args, $configuration, new Config(paths: ['.'])); + return Project::fromConfig($config, $root, $file, $commandLine); + } + + + /** + * The versions to measure the upgrade to: the package and the version named, PHP to the version named or to the + * newest its data know, and without a target what the estimator proposes. + * @return list + * @throws UsageException + */ + private function resolveTargets(ParseResult $args, ?string $target, ?string $version, Estimator $estimator): array + { + return match (true) { + $version !== null && !Versions::isVersion($version) + => throw new UsageException("The version to upgrade to is written as `8.4` or `4.0`, `$version` given.", $args->command), + $target === null => $estimator->proposeTargets(), + $target === PackageDiscovery::Php => $version === null ? $estimator->proposeTargets(phpOnly: true) : [Target::php($version)], + !Target::isPackageName($target) + => throw new UsageException("The target is `php` or a package as `vendor/name`, `$target` given.", $args->command), + $version === null => throw new UsageException("Name the version of `$target` to upgrade to, as `$target 4.0`.", $args->command), + default => [new Target($target, $version)], + }; + } + + + /** + * Whether the plan is the one of a library, which widens its constraints, where no option says, and why: by the `type` + * of the project and the lock it has none of, and for a library with a lock of its own in a terminal as the user + * answers, otherwise as the application the lock makes it. + * @return array{bool, string} + */ + private function decideLibrary(bool $mayAsk, Project $project): array + { + $packages = ProjectPackages::read($project->root); + $type = $packages->findType(); + $typed = ($packages->rootPath !== null && isset(Config\ComposerJson::read("$packages->rootPath/composer.json")['type'])) + ? "`composer.json` gives the type `$type`" + : '`composer.json` gives no type, which Composer reads as `library`'; + return match ($packages->isLibrary()) { + Tristate::Yes => [true, "$typed, and there is no `composer.lock`"], + Tristate::No => [false, $typed], + Tristate::Maybe => $mayAsk + ? [$this->context->prompt->choose( + "$typed, yet the project has a `composer.lock` of its own. Plan it as a library, which widens its constraints to the targets instead of raising them?", + ['y', 'n'], + 'y', + ) === 'y', 'as you answered'] + : [ + false, + "$typed, yet the project has a `composer.lock` of its own, as an application has; `--library` plans it as a library", + ], + }; + } + + + /** + * Whether the upgrade goes with the types of the code: as `--types` says, as the configuration says, else in a + * terminal as the user answers, and otherwise without them. + * @throws ConfigurationException for the types asked for without PHPStan beside DressCode + */ + private function decideTypes(?bool $types, bool $mayAsk, Config $config): bool + { + $available = Analyses\PhpStan::isAvailable(); + if ($types === true && !$available) { + throw new ConfigurationException( + '`--types` asks for the types of the code, but `phpstan/phpstan` is not installed beside DressCode.', + docs: 'types#enable', + ); + } elseif ($types !== null) { + return $types; + } elseif ($config->typeAnalysis !== null || !$available) { + return $available; + } + + return $mayAsk + && $this->context->prompt->choose( + 'Measure with the types of the code, from PHPStan with the configuration of the project where it has one? They decide most of what a fix of a call needs to know.', + ['y', 'n'], + 'y', + ) === 'y'; + } + + + /** + * The workers repeating the check of the estimate, `upgrade estimate` reading it from a file in the cache directory; + * null for one job, or where there is no PHP to start one with. + */ + private function createEstimateWorkers(ConfigOptions $configuration, UpgradeCheck $check, int $jobs, string $cacheDir): ?WorkerPool + { + $php = WorkerPool::buildPhpCommand(); + if ($jobs < 2 || $php === null) { + return null; + } + + $file = "$cacheDir/upgrade/estimate-" . bin2hex(random_bytes(8)) . '.json'; + $check->write($file); + register_shutdown_function(fn() => @unlink($file)); // @ a worker may hold it still + + $command = [...$php, $this->context->scriptFile, 'upgrade', 'estimate', '--no-color', "--check-file=$file"]; + foreach (['--config' => (array) $configuration->config, '--root' => (array) $configuration->root] as $option => $values) { + foreach ($values as $value) { + $command[] = "$option=$value"; + } + } + + return new WorkerPool($command, $jobs, $this->context->workingDirectory, warmFirst: $check->layer->typeAnalysis !== null); + } + + + /** The run of a worker of the estimate: the check repeated as the parent wrote it, the files coming over the connection. */ + private function runEstimateWorker(ParseResult $args, string $address, string $file, ConfigOptions $configuration): int + { + ['config' => $config, 'root' => $root] = $this->context->loadConfig($args, $configuration, new Config(paths: ['.'])); + [$factory, $resolution] = UpgradeCheck::read($file)->resolve($config, $root); + $runner = $factory->createRunner($resolution, cache: false); + ($runner->warmUp)?->__invoke(); // before connecting, which is what starts the other workers + return Worker::run($address, $runner, fix: false); + } +} diff --git a/src/Console/ConfigPrinter.php b/src/Console/ConfigPrinter.php index 32c383c435..8eead55c74 100644 --- a/src/Console/ConfigPrinter.php +++ b/src/Console/ConfigPrinter.php @@ -40,12 +40,12 @@ public function __construct( /** The report as text for the console, with the ANSI colors of `$console` where it draws any. */ public function print(Console $console): string { - $out = $console->color('gray', 'Use ') + $out = Markup::formatLabel($console, 'Use') . (implode(', ', [...$this->config->plugins, ...$this->config->use]) ?: '(none)') . "\n"; - $out .= $console->color('gray', 'Targets ') . self::describeTargets($this->config) . "\n"; - $out .= $console->color('gray', 'Style ') . self::describeStyle($this->config) . "\n"; - $out .= $console->color('gray', 'Names ') . self::describeNamespaces($this->config) . "\n"; + $out .= Markup::formatLabel($console, 'Targets') . self::describeTargets($this->config) . "\n"; + $out .= Markup::formatLabel($console, 'Style') . self::describeStyle($this->config) . "\n"; + $out .= Markup::formatLabel($console, 'Names') . self::describeNamespaces($this->config) . "\n"; foreach ([[$this->config->namespacedFunctions, '()'], [$this->config->namespacedConstants, '']] as [$names, $suffix]) { foreach ($names as $name => $source) { $out .= ' ' . self::pad($name . $suffix, 56) . $console->color('gray', $source) . "\n"; @@ -53,7 +53,7 @@ public function print(Console $console): string } if ($this->config->suppressionComments !== []) { - $out .= $console->color('gray', 'Comments ') . 'silence decisions on their line' . "\n"; + $out .= Markup::formatLabel($console, 'Comments') . 'silence decisions on their line' . "\n"; foreach ($this->config->suppressionComments as $pattern => $names) { $example = $this->config->suppressionExamples[$pattern] ?? null; $out .= ' ' @@ -65,7 +65,7 @@ public function print(Console $console): string } if ($this->packages !== []) { - $out .= $console->color('gray', 'Packages ') . count($this->packages) . ' with upgrading data' . "\n"; + $out .= Markup::formatLabel($console, 'Packages') . count($this->packages) . ' with upgrading data' . "\n"; // what the version of the package has not reached yet is what an upgrade still offers foreach ($this->packages as [$data, $version]) { $further = array_map( @@ -82,7 +82,7 @@ public function print(Console $console): string } $set = array_filter($this->config->decisions, fn(ResolvedDecision $decision) => $decision->layers !== []); - $out .= $console->color('gray', 'Decisions ') + $out .= Markup::formatLabel($console, 'Decisions') . sprintf('%d of %d set by a layer, the others asking for nothing', count($set), count($this->config->decisions)) . "\n\n"; // a decision the configuration names stands there too, so that a name doing nothing shows $named = array_filter( diff --git a/src/Console/EstimatePrinter.php b/src/Console/EstimatePrinter.php new file mode 100644 index 0000000000..f6c762a8ef --- /dev/null +++ b/src/Console/EstimatePrinter.php @@ -0,0 +1,276 @@ + ['deprecations', 'cleanup'], 'May modernize' => ['modernizations']]; + + /** what the run can do about a finding, as the output says it */ + private const FixabilityWords = [ + 'automatic' => 'automatic', + 'typeUnknown' => 'unknown types', + 'nameUncertain' => 'uncertain names', + 'manual' => 'manual', + ]; + + + public function __construct( + private Estimate $estimate, + ) { + } + + + public function print(Console $console): string + { + $estimate = $this->estimate; + $targets = implode(', ', array_map(fn(Target $target) => $target->format(), $estimate->targets)); + $out = Markup::formatLabel($console, 'Upgrade') . $targets + . ' over ' . Markup::formatCount($estimate->files, 'file') + . ($estimate->namesMeasured ? ', the names resolved for certain as measured' : '') + . ($estimate->types ? '' : ', without the types of the code') . "\n\n"; + + $width = max([0, ...array_map(fn(Topic $topic) => strlen($topic->name), $estimate->topics)]); + foreach (self::Groups as $title => $sets) { + $topics = array_values(array_filter($estimate->topics, fn(Topic $topic) => in_array($topic->set, $sets, true))); + if ($topics === []) { + continue; + } + + $places = array_sum(array_map(fn(Topic $topic) => $topic->countPlaces(), $topics)); + $out .= $console->color('white', $title) . ', ' . Markup::formatCount(count($topics), 'topic') + . ' with ' . Markup::formatCount($places, 'finding') . "\n"; + foreach ($topics as $topic) { + $out .= sprintf( + " %s %s %s, %s\n", + str_pad($topic->name, $width), + str_pad( + Markup::formatCount($topic->countPlaces(), 'finding') . ' in ' . Markup::formatCount($topic->files, 'file'), + 24, + ), + $console->color(...match ($topic->phase) { + Phase::Bridge => ['green', $topic->constraint === null ? 'works today' : "works on $topic->constraint"], + Phase::Jump => ['yellow', $topic->constraint === null ? 'after the raise' : "needs $topic->constraint"], + }), + self::formatPlaces($topic), + ); + } + + $out .= "\n"; + } + + if ($estimate->topics === []) { + $out .= $estimate->isComplete() + ? "Nothing to upgrade: no rule and no upgrading data find anything for the targets.\n\n" + : "Nothing found for the targets in what the check could read, which the lines below say is not all.\n\n"; + } + + $today = array_sum(array_map( + fn(Topic $topic) => $topic->countPlaces(Fixability::Automatic), + $estimate->findTopics(Phase::Bridge, 'deprecations'), + )); + if ($today > 0) { + $out .= Markup::formatLabel($console, 'Today') . Markup::formatCount($today, 'finding') + . ' of `deprecations` ' . ($today === 1 ? 'is' : 'are') . " rewritten on the versions allowed now by `dresscode upgrade today`\n"; + } + + if (!$estimate->blindSpots->isEmpty()) { + $out .= Markup::formatLabel($console, "Can't see") . $this->formatBlindSpots() . "\n"; + } + + if ($estimate->unprocessed !== []) { + $out .= Markup::formatLabel($console, 'Unverified') . Markup::formatCount(count($estimate->unprocessed), 'file') + . ' the check could not process, whose findings are not counted: ' . self::formatList(array_keys($estimate->unprocessed)) . "\n"; + } + + foreach ($estimate->blockers as $blockers) { + $out .= Markup::formatLabel($console, 'Composer') . $blockers->target->format() . ': ' . self::formatBlockers($blockers) . "\n"; + } + + $major = $estimate->moves?->findMajor() ?? []; + if ($estimate->library) { + $out .= Markup::formatLabel($console, 'Moves') . "nothing, a library widens its constraints with `--no-update`\n"; + } elseif ($estimate->moves?->unknown !== null) { + $out .= Markup::formatLabel($console, 'Moves') . "unknown, {$estimate->moves->unknown}\n"; + } elseif ($major !== []) { + $out .= Markup::formatLabel($console, 'Moves') . self::wrapList(array_map( + fn(Move $move) => "$move->package $move->from => $move->to" + . match (true) { + $move->isKnown($estimate->knownVersions) => '', + $move->isSeriesKnown($estimate->knownVersions) => " (data up to {$estimate->knownVersions[$move->package]})", + default => ' (no data)', + }, + $major, + )) . "\n"; + } + + foreach ($estimate->missingData as $package => $libraries) { + $out .= Markup::formatLabel($console, 'No data') . self::formatList($libraries) + . ": the upgrading data are in `$package`, which `dresscode upgrade today` offers to install, with `--yes` without asking\n"; + } + + $out .= Markup::formatLabel($console, 'Data') . (self::wrapList(array_map( + fn(string $library, string $version) => "$library up to $version", + array_keys($estimate->knownVersions), + $estimate->knownVersions, + )) ?: 'no upgrading data reach the project') . "\n"; + foreach ($estimate->warnings as $warning) { + $out .= "\n" . Markup::highlightCode($console, "Warning: $warning", 'yellow'); + } + + return $out . ($estimate->warnings === [] ? '' : "\n"); + } + + + /** + * The estimate as the data the format `json` writes. + * @return array + */ + public function toArray(): array + { + $estimate = $this->estimate; + return [ + 'version' => self::Version, + 'targets' => array_map(fn(Target $target) => (array) $target, $estimate->targets), + 'files' => $estimate->files, + 'namesMeasured' => $estimate->namesMeasured, + 'types' => $estimate->types, + 'topics' => array_map(fn(Topic $topic) => [ + 'name' => $topic->name, + 'set' => $topic->set, + 'phase' => $topic->phase->value, + 'constraint' => $topic->constraint, + 'files' => $topic->files, + 'findings' => ['total' => $topic->countPlaces(), ...$topic->places], + ], $estimate->topics), + 'blindSpots' => [...(array) $estimate->blindSpots, 'otherFiles' => (object) $estimate->blindSpots->otherFiles], + 'blockers' => array_map(fn(Blockers $blockers) => [ + 'target' => (array) $blockers->target, + 'packages' => array_map(fn(Blocker $blocker) => (array) $blocker, $blockers->packages), + 'unknown' => $blockers->unknown, + ], $estimate->blockers), + 'moves' => $estimate->moves === null ? null : [ + 'packages' => array_map(fn(Move $move) => [ + ...(array) $move, + 'major' => $move->isMajor(), + 'data' => $move->isKnown($estimate->knownVersions), + 'dataUpTo' => $estimate->knownVersions[$move->package] ?? null, + ], $estimate->moves->packages), + 'unknown' => $estimate->moves->unknown, + ], + 'library' => $estimate->library, + 'unverified' => TasksPrinter::listUnverified($estimate->unprocessed), + 'missingData' => (object) $estimate->missingData, + 'knownVersions' => (object) $estimate->knownVersions, + 'warnings' => $estimate->warnings, + ]; + } + + + /** What the run can do about the findings of the topic: `automatic`, or the count of each. */ + private static function formatPlaces(Topic $topic): string + { + $places = array_filter($topic->places); + return array_keys($places) === [Fixability::Automatic->value] + ? self::FixabilityWords[Fixability::Automatic->value] + : implode(', ', array_map( + fn(string $fixability, int $count) => "$count " . self::FixabilityWords[$fixability], + array_keys($places), + $places, + )); + } + + + /** What the upgrade does not see, each kind of it counted. */ + private function formatBlindSpots(): string + { + $spots = $this->estimate->blindSpots; + return implode('; ', array_filter([ + $spots->classNameStrings + ? Markup::formatCount($spots->classNameStrings, 'string') . ' naming a class the data rename or forbid' + : null, + $spots->dynamicAccesses ? Markup::formatCount($spots->dynamicAccesses, 'place') . ' reached by a computed name' : null, + $spots->otherFiles + ? implode(', ', array_map( + fn(string $extension, int $count) => Markup::formatCount($count, ".$extension file"), + array_keys($spots->otherFiles), + $spots->otherFiles, + )) . ', which no rule reads' + : null, + $spots->generatedFiles ? Markup::formatCount($spots->generatedFiles, 'generated file') . ', left out' : null, + ])); + } + + + /** The packages refusing the target, or why nothing is known of them. */ + public static function formatBlockers(Blockers $blockers): string + { + return match (true) { + $blockers->unknown !== null => "unknown, $blockers->unknown", + $blockers->packages === [] => 'nothing in the lock refuses it', + default => 'refused by ' . self::formatList(array_map( + fn(Blocker $blocker) => "$blocker->package $blocker->version ($blocker->constraint)", + $blockers->packages, + )), + }; + } + + + /** + * The first names of the list, the rest counted. + * @param list $names + */ + private static function formatList(array $names): string + { + return implode(', ', array_slice($names, 0, self::ListedNames)) + . (count($names) > self::ListedNames ? ' and ' . (count($names) - self::ListedNames) . ' more' : ''); + } + + + /** + * The items separated by commas, broken onto lines that keep the width of the output, each under the value. + * @param list $items + */ + private static function wrapList(array $items): string + { + $lines = []; + $line = ''; + foreach ($items as $i => $item) { + $item .= $i < count($items) - 1 ? ',' : ''; + if ($line !== '' && strlen("$line $item") > self::Width - Markup::LabelWidth) { + $lines[] = $line; + $line = $item; + } else { + $line = $line === '' ? $item : "$line $item"; + } + } + + return implode("\n" . str_repeat(' ', Markup::LabelWidth), [...$lines, $line]); + } +} diff --git a/src/Console/FormatterChoice.php b/src/Console/FormatterChoice.php new file mode 100644 index 0000000000..108cb7e1a8 --- /dev/null +++ b/src/Console/FormatterChoice.php @@ -0,0 +1,28 @@ + the other configurations the project has, which nobody chose between */ + public array $unchosen = [], + /** named on the command line, which is consent to read it even where reading runs it */ + public bool $named = false, + ) { + } +} diff --git a/src/Console/InitPrinter.php b/src/Console/InitPrinter.php new file mode 100644 index 0000000000..56a2a2c1eb --- /dev/null +++ b/src/Console/InitPrinter.php @@ -0,0 +1,470 @@ + $unchosen the other configurations of formatters the project has, where nobody chose + */ + public function printScope(ProjectFacts $project, Scope $scope, array $unchosen = []): string + { + $out = $this->label('Project') . sprintf( + "%s %s%s\n", + Markup::formatCount(count($scope->files), 'PHP file'), + $scope->isFromGit() ? 'in git' : 'in ' . implode(', ', $scope->listPaths()), + $project->name === null ? '' : ", $project->name", + ); + $out .= $this->printLeftOut(array_filter($scope->listExcludePaths(), fn(LeftOutReason $why) => $why !== LeftOutReason::Unparsed)); + if ($scope->untracked) { + $out .= $this->label('') . 'not measured: ' . $this->formatFiles(count($scope->untracked)) + . " git does not track, which the first fix takes too\n"; + } + + if ($config = $project->formatterConfig) { + $out .= $this->label('Formatter') + . "{$config->formatter->value}, `$config->file`" + . ($config->runBy === null ? '' : ", run by `$config->runBy`") + . "\n" + . ($config->unread === [] + ? '' + : $this->label('') . 'its scope may differ, as `' . implode('`, `', $config->unread) . "` could not be read\n") + . ($unchosen === [] + ? '' + : $this->label('') . 'taken as the first, the project having also ' + . implode(' and ', array_map(fn(FormatterCandidate $other) => $other->format(), $unchosen)) + . "; a terminal asks which, without `--yes`\n"); + } + + $lowest = Versions::findLowestVersion($project->phpTarget) ?? $project->phpTarget; + return $out . $this->label('PHP') . match ($project->phpTargetSource) { + PhpVersionSource::Default => "$lowest, `composer.json` names none", + default => "$lowest and newer, from `composer.json`", + } . (version_compare($lowest, '8.0', '<') ? '; no fix writes what PHP 8 brought' : '') . "\n"; + } + + + /** The standard, and what the code departs from it in and what of that the file writes as the code has it. */ + public function printMeasurements(Proposal $proposal): string + { + $standard = $proposal->standard; + $changed = $standard->changedFiles; + $chosen = implode(', ', $standard->presets); + $next = array_key_first(array_diff_key($changed, [$chosen => true])); + $out = $this->label('Standard') . "$chosen (" . $standard->describe() . ')' . match ($standard->reason) { + StandardReason::Given => ", as given\n", + StandardReason::Formatter => ', ' . Formatter::Pint->value . " formats the project in this style\n", + StandardReason::Successor => sprintf( + ", the successor of psr12, under which a fix changes fewer files, %d; `--use` names another\n", + $changed['psr12'], + ), + StandardReason::Fewest => sprintf( + ", the nearest to your code by the files a fix changes%s; `--use` names another\n", + $next === null ? '' : ", $changed[$next] under $next", + ), + }; + $out .= $this->label('Measured') . 'your code departs from it in ' . $this->formatDecisions(count($standard->residue)) + . ' over ' . $this->formatFiles(count($proposal->sample->files)) + . ($changed[$chosen] ?? 0 + ? "; $chosen as it is, with no decision below `use`, would rewrite {$changed[$chosen]} of them" + : '') + . "\n"; + if ($proposal->isSampled()) { + $directories = $proposal->sample->directories; + $out .= $this->label('Sample') . count($proposal->sample->files) . ' of ' . count($proposal->scope->files) . ' files' + . (count($directories) > 1 + ? ', every directory its share: ' . implode(', ', array_map( + fn(string $directory, array $counts) => ($directory === '' ? '.' : $directory) . " $counts[0] of $counts[1]", + array_keys(array_slice($directories, 0, 5)), + array_slice($directories, 0, 5), + )) . (count($directories) > 5 ? ' and more' : '') + : '') + . "\n"; + } + + $taken = array_values(array_filter($proposal->values, fn(ProposedValue $proposed) => !$proposed->isKept())); + foreach (array_slice($taken, 0, self::ListedValues) as $i => $proposed) { + $out .= $this->label($i === 0 ? 'As written' : '') + . "$proposed->path: " . $proposal->standard->formatValue($proposed->path, $proposed->value) . " $proposed->comment\n"; + } + + if (count($taken) > self::ListedValues) { + $out .= $this->label('') . sprintf("and %d more, each in the file\n", count($taken) - self::ListedValues); + } + + foreach ($proposal->findRegions() as $i => $region) { + $out .= $this->label($i === 0 ? 'Differs' : '') . sprintf( + "%s: %s %s in %s files; an override for `%s` would keep it, none is written\n", + $region->directory, + $region->path, + $proposal->standard->formatValue($region->path, $region->value), + $region->files === $region->measured ? "all $region->measured" : "$region->files of $region->measured", + $region->directory, + ); + } + + $unchecked = count(array_filter( + $proposal->values, + fn(ProposedValue $proposed) => $proposed->isKept() && $proposed->origin !== ValueOrigin::OverBudget, + )); + return $out . ($unchecked + ? $this->label('Unchecked') + . $this->formatDecisions($unchecked) + . ($unchecked === 1 + ? " left as your code has it (`keep`), with the reason in the file\n" + : " left as your code has them (`keep`), each with the reason in the file\n") + : ''); + } + + + /** + * What a fix of the configuration would change over the whole scope, what it leaves, and what the budget left + * unchecked; the files a check found unparsable, which the configuration leaves out. + */ + public function printVerification(Proposal $proposal): string + { + $verification = $proposal->verification ?? throw new \LogicException('The proposal is not verified.'); + $out = $this->label('Checked') . sprintf( + "%s: a fix changes %s%s%s\n", + count($proposal->scope->files) === 1 ? 'the 1 file' : 'all ' . Markup::formatCount(count($proposal->scope->files), 'file'), + $this->formatFiles($verification->changedFiles), + $verification->foundationFiles + ? ", $verification->foundationFiles of them for the indentation, the line endings or the long lines the rest of the code has" + : '', + $verification->leftFindings + ? sprintf( + '; %s in %s that no rule fixes go to the baseline', + Markup::formatCount($verification->leftFindings, 'finding'), + $this->formatFiles($verification->leftFiles), + ) + : '', + ); + $dropped = count(array_filter($proposal->values, fn(ProposedValue $proposed) => $proposed->origin === ValueOrigin::OverBudget)); + if ($dropped) { + $out .= $this->label('') . ($dropped === 1 ? '1 more decision is' : "$dropped more decisions are") + . ' left as your code has ' + . ($dropped === 1 ? 'it' : 'them') + . ", as the fixes together would change more than {$this->formatFiles($verification->budget)}" + . ", the indentation, the line endings, the long lines and your answers aside (`--budget` raises the limit)\n"; + } + + $failed = $verification->result->failed; + if ($failed) { + $out .= $this->label('Failed') . $this->formatFiles(count($failed)) . ' a rule failed in, of which nothing above is counted: ' + . implode(', ', array_slice($failed, 0, self::ListedLeftOut)) . (count($failed) > self::ListedLeftOut ? ' and more' : '') . "\n"; + } + + return $out . $this->printLeftOut(array_filter( + $proposal->scope->listExcludePaths(), + fn(LeftOutReason $why) => $why === LeftOutReason::Unparsed, + )); + } + + + /** The commands to run next: a fix where it changes something, then the baseline where something remains. */ + public function printNext(Proposal $proposal, bool $written = true): string + { + $verification = $proposal->verification ?? throw new \LogicException('The proposal is not verified.'); + [$changed, $left] = [$verification->changedFiles, $verification->leftFindings]; + $out = ($written + ? $this->label('Written') . "`dresscode.neon`\n" + : $this->label('Proposed') . "`dresscode.neon` below, nothing written, as `--dry-run` asks\n") + . $this->label('Next') . match (true) { + $changed > 0 => "`dresscode fix` rewrites {$this->formatFiles($changed)} in place (`dresscode check --diff` shows the changes first); until then `check` reports them\n", + $left > 0 + => "`dresscode baseline` records the {$this->formatFindings($left)} that no rule fixes, so that `check` passes\n", + $verification->result->failed !== [] => "`dresscode check` reports the files a rule failed in\n", + default => "`dresscode check` passes already\n", + }; + return $out . ($changed > 0 && $left > 0 + ? $this->label('Then') + . "`dresscode baseline` records the {$this->formatFindings($left)} in {$this->formatFiles($verification->leftFiles)} that no rule fixes, so that `check` passes\n" + : '') + . ($proposal->typesVerification === null + ? '' + : $this->label('Types') . 'checked once more with the types of the code, which the counts above go without: the first fix rewrites ' + . $this->formatFiles($proposal->typesVerification->changedFiles) + . " and leaves {$this->formatFindings($proposal->typesVerification->leftFindings)} then\n") + . (($failed = $proposal->findFailedFiles()) === [] + ? '' + : $this->label('Partial') . 'a rule failed in ' . $this->formatFiles(count($failed)) + . ", which the measuring left out, so the decisions rest on the other files, as the head of `dresscode.neon` says\n"); + } + + + /** Why nothing was written: a rule failed under every standard, so none of them could be chosen as the nearest. */ + public function printUnsure(Proposal $proposal): string + { + $failed = $proposal->standard->failed; + return $this->label('Failed') . 'a rule failed under every standard in ' . implode( + ', ', + array_slice($failed, 0, self::ListedLeftOut), + ) + . (count($failed) > self::ListedLeftOut ? ' and more' : '') + . ", so none of them can be chosen as the nearest and nothing was written; `--use ` measures against the one you name\n"; + } + + + /** + * A question asked in a terminal, numbered in its round where the round has more: what it asks, a place of the code + * it is about, and its answers by number. + */ + public function printQuestion(Question $question, int $number = 1, int $count = 1): string + { + $out = ($count > 1 ? "($number/$count) " : '') + . $question->text + . "\n" + . ($question->example === null ? '' : $this->label('') . 'e.g. ' . $question->example->describe() . "\n"); + foreach ($question->answers as $i => $answer) { + $out .= ' [' . ($i + 1) . "] $answer->label" . ($i === 0 ? ' (recommended)' : '') . "\n"; + } + + return $out; + } + + + /** The questions the answers given have left to the user, which no round before could ask. */ + public function printNextRound(int $count): string + { + return 'Your answers leave ' . ($count === 1 ? 'one more question' : "$count more questions") . ":\n"; + } + + + /** + * What is the user's to decide where nobody was asked: each question as the line or the command that answers it + * otherwise than the recommended answer init took. + * @param list $questions + */ + public function printInstructions(array $questions): string + { + $out = ''; + foreach ($questions as $question) { + $out .= $this->label($out === '' ? 'Your call' : '') . $question->instruction . "\n"; + } + + return $out; + } + + + /** The configuration of the formatter that could not be read, so its rules are not compared with the code. */ + public function printUnreadable(FormatterConfig $config, string $error): string + { + return $this->label('Formatter') . "`$config->file` could not be read, so its rules are not compared with your code: $error\n"; + } + + + /** + * What has moved since the configuration that exists was written, and how to see what a value changes. + * @param list $shifts + */ + public function printShifts(Proposal $proposal, array $shifts, string $file): string + { + $overridden = array_keys(array_filter($proposal->scope->leftOut, fn(LeftOutReason $why) => $why === LeftOutReason::Overridden)); + $compared = $this->label('Compared') . "the code with the standard and the decisions of `$file`, over the files it checks" + . ($overridden === [] + ? '' + : ', but ' . $this->formatFiles(count($overridden)) . ' an override of it gives decisions of their own') + . "\n"; + $out = ''; + foreach ($shifts as $shift) { + $out .= $this->label($out === '' ? 'Moved' : '') + . "$shift->path: `$file` has " + . ($shift->current ?? 'nothing') + . match ($shift->kind) { + ShiftKind::Measured => ($shift->cost + ? ', a fix of it rewrites ' . ($proposal->isSampled() ? 'about ' : '') . $this->formatFiles($shift->cost) + : '') + . ", init now writes $shift->measured ($shift->comment)\n", + ShiftKind::HeldToStandard => ", the code holds to $shift->measured of the standard\n", + }; + } + + return $compared . ($out ?: $this->label('Moved') . "nothing: `$file` says what init measures\n") + . "\n`$file` exists, so nothing is written; `dresscode check --set path=value` shows what a value would change.\n"; + } + + + /** Says that an override of the configuration that exists gives every file it checks decisions of their own. */ + public function printNothingCompared(string $file): string + { + return $this->label('Compared') . "nothing, as an override of `$file` gives every file it checks decisions of their own, which init does not measure\n" + . "\n`$file` exists, so nothing is written.\n"; + } + + + /** + * What else the project may do: run DressCode where it runs its formatter, translate the rules of the formatter, which + * the file only compared with the code or did not read, and rewrite the comments that silence it. + */ + public function printAlso(ProjectFacts $project, int $suppressions, bool $rulesRead): string + { + $config = $project->formatterConfig; + if ($config === null) { + return ''; + } + + $out = $this->label('Also') . "`dresscode check` can take the place of {$config->formatter->value}" + . ($config->runBy === null ? ' where you run it' : " in `$config->runBy`") + . "\n" . $this->label('') . match (true) { + $rulesRead => 'its rules were compared with your code, not carried over', + $config->formatter->runsCode() => "its rules were not read, as `$config->file` is PHP", + default => 'its rules could not be read', + } . "; `dresscode migrate config $config->file` translates them instead of measuring the code\n"; + return $out . ($suppressions ? $this->label('') . Markup::formatSuppressionsHint($suppressions) . "\n" : ''); + } + + + /** + * The proposal as the data the format `json` writes: the scope, the standard, what the file writes and why, what the + * check over the whole scope says and the lines of `dresscode.neon` answering what init would ask. + * @param list $asked the questions the recommended answers were taken for + * @param ?string $configuration the configuration init proposes; null over one that exists, or none chosen + * @param ?string $written the file the configuration was written to + * @param ?list $shifts what has moved since the configuration that exists, the code being compared with it + * @return array + */ + public function toArray( + Proposal $proposal, + array $asked = [], + ?string $configuration = null, + ?string $written = null, + ?array $shifts = null, + ): array + { + $standard = $proposal->standard; + $verification = $proposal->verification; + return [ + ...self::toScopeArray($proposal->project->root, $proposal->scope), + 'sample' => ['files' => count($proposal->sample->files), 'directories' => (object) $proposal->sample->directories], + 'standard' => [ + 'use' => $standard->presets, + 'reason' => lcfirst($standard->reason->name), + 'changedFiles' => (object) $standard->changedFiles, + 'alternatives' => $standard->alternatives, + 'unsure' => $standard->unsure, + ], + 'values' => array_map(fn(ProposedValue $proposed) => [ + 'path' => $proposed->path, + 'value' => $proposed->value, + 'comment' => $proposed->comment, + 'origin' => lcfirst($proposed->origin->name), + ], array_values($proposal->values)), + 'regions' => array_map(fn(Region $region) => (array) $region, $proposal->findRegions()), + 'verification' => $verification === null ? null : [ + 'changedFiles' => $verification->changedFiles, + 'foundationFiles' => $verification->foundationFiles, + 'leftFindings' => $verification->leftFindings, + 'leftFiles' => $verification->leftFiles, + ], + 'instructions' => array_map(fn(Question $question) => $question->instruction, $asked), + 'failed' => $proposal->findFailedFiles(), + 'configuration' => $configuration, + 'written' => $written, + ...$shifts === null + ? [] + : ['shifts' => array_map(fn(Shift $shift) => [...(array) $shift, 'kind' => lcfirst($shift->kind->name)], $shifts)], + ]; + } + + + /** + * The root and the scope as the data the format `json` writes, all it writes where nothing was measured. + * @return array + */ + public static function toScopeArray(string $root, Scope $scope): array + { + return [ + 'version' => self::Version, + 'root' => $root, + 'scope' => [ + 'files' => count($scope->files), + 'paths' => $scope->listPaths(), + 'excluded' => (object) array_map(fn(LeftOutReason $why) => $why->value, $scope->listExcludePaths()), + 'untracked' => $scope->untracked, + ], + ]; + } + + + /** @param array $entries path => why */ + private function printLeftOut(array $entries): string + { + $out = ''; + $width = $entries ? max(array_map(strlen(...), array_slice(array_keys($entries), 0, self::ListedLeftOut))) : 0; + foreach (array_slice($entries, 0, self::ListedLeftOut, preserve_keys: true) as $path => $why) { + $out .= $this->label($out === '' ? 'Excluded' : '') . str_pad($path, $width) . " $why->value\n"; + } + + $more = count($entries) - self::ListedLeftOut; + return $out . ($more > 0 ? $this->label('') . "and $more more, each in `excludePaths`\n" : ''); + } + + + private function formatDecisions(int $count): string + { + return match ($count) { + 0 => 'no decision', + 1 => '1 decision', + default => "$count decisions", + }; + } + + + private function formatFiles(int $count): string + { + return match ($count) { + 0 => 'no file', + 1 => '1 file', + default => "$count files", + }; + } + + + private function formatFindings(int $count): string + { + return Markup::formatCount($count, 'finding'); + } + + + private function label(string $label): string + { + return Markup::formatLabel($this->console, $label); + } +} diff --git a/src/Console/Markup.php b/src/Console/Markup.php index 82dfb3031c..db5f5bebcb 100644 --- a/src/Console/Markup.php +++ b/src/Console/Markup.php @@ -18,6 +18,9 @@ */ final class Markup { + /** the width of the label a line of the output opens with */ + public const LabelWidth = 11; + private const DocsUrl = 'https://dresscode.run/'; private const CodeColor = '#87D7FF'; @@ -122,4 +125,18 @@ public static function formatCount(int $count, string $noun): string { return $count . ' ' . $noun . ($count === 1 ? '' : 's'); } + + + /** The line pointing a project at the phpcs suppression comments it has. */ + public static function formatSuppressionsHint(int $count): string + { + return self::formatCount($count, 'phpcs suppression comment') . ': `dresscode migrate suppressions` rewrites them'; + } + + + /** The label a line of the output opens with, in gray and as wide as the others, so that the values line up. */ + public static function formatLabel(Console $console, string $label): string + { + return $console->color('gray', str_pad($label, self::LabelWidth)); + } } diff --git a/src/Console/PlanPrinter.php b/src/Console/PlanPrinter.php new file mode 100644 index 0000000000..2e76c98f8a --- /dev/null +++ b/src/Console/PlanPrinter.php @@ -0,0 +1,101 @@ +mode === null ? '' : Markup::formatLabel($console, 'Mode') . "$this->mode\n") + . Markup::formatLabel($console, 'Plan') + . implode(', ', array_map(fn(Target $target) => $target->format(), $this->plan->targets)) + . ($this->plan->paths === [] ? '' : ' over ' . implode(', ', $this->plan->paths)) + . "\n"; + $width = max([0, ...array_map(fn(Step $step) => strlen($step->title), $this->plan->steps)]); + foreach ($this->plan->steps as $index => $step) { + $out .= rtrim(sprintf( + ' %2d %s %s %s', + $index + 1, + $console->color(...match (true) { + $step->skip => ['gray', 'skipped '], + $step instanceof ManualStep => ['yellow', 'manual '], + $step instanceof DependencyStep => ['white', 'composer'], + default => ['green', 'fix '], + }), + str_pad($step->title, $width), + match (true) { + $step instanceof FixStep => implode(', ', $step->topics), + $step instanceof ManualStep => $step->formatCommand(), + default => '', + }, + )) . "\n"; + } + + $test = $this->plan->testCommand; + $quick = $this->plan->quickTestCommand; + return $out . Markup::formatLabel($console, 'Tests') . match (true) { + $test === null && $quick === null => 'not run, no `--test-command` given', + $test === null || $quick === null => Violation::formatCode($test ?? $quick) . ' after every step', + default => Violation::formatCode($quick) + . ' after every step, ' + . Violation::formatCode($test) + . ' after a raise and the last one', + } . "\n"; + } + + + /** + * The plan as the data the format `json` writes. + * @return array + */ + public function toArray(): array + { + return [ + 'version' => self::Version, + 'targets' => array_map(fn(Target $target) => (array) $target, $this->plan->targets), + 'steps' => array_map(fn(Step $step) => [ + ...PlanFile::encodeStep($step), + 'commands' => match (true) { + $step instanceof DependencyStep => $step->formatCommands(), + $step instanceof ManualStep => [$step->formatCommand()], + default => [], + }, + 'skip' => $step->skip, + ], $this->plan->steps), + 'toolVersion' => $this->plan->toolVersion, + 'dataVersions' => (object) $this->plan->dataVersions, + 'testCommand' => $this->plan->testCommand, + 'quickTestCommand' => $this->plan->quickTestCommand, + 'paths' => $this->plan->paths, + 'unverified' => $this->plan->unestimated, + 'missingData' => (object) $this->plan->missingData, + ]; + } +} diff --git a/src/Console/ProgressBar.php b/src/Console/ProgressBar.php index dc94278bf7..85f0e94977 100644 --- a/src/Console/ProgressBar.php +++ b/src/Console/ProgressBar.php @@ -76,18 +76,10 @@ public function advance(int $done, array $running = [], ?int $size = null): void asort($running); $slowest = array_key_first($running); - if ($slowest !== null) { - $elapsed = $now - $running[$slowest]; - $note = match (true) { - $large => self::formatSize($size), - $elapsed > self::SlowFile => sprintf('%ds', (int) $elapsed), - default => null, - }; - if ($note !== null) { - $room = $this->console->getWidth() - Ansi::measure($line) - Ansi::measure($note) - 4; - $line .= $this->console->color('gray', ' ' . Ansi::truncate($slowest, max(1, $room), keepEnd: true)) - . $this->console->color('yellow', " $note"); - } + if ($slowest !== null && ($large || $now - $running[$slowest] > self::SlowFile)) { + $note = ' ' . ($large ? $this->console->color('yellow', self::formatSize($size)) : StatusLine::formatSpinner($this->console)); + $room = $this->console->getWidth() - Ansi::measure($line) - Ansi::measure($note) - 2; + $line .= $this->console->color('gray', ' ' . Ansi::truncate($slowest, max(1, $room), keepEnd: true)) . $note; } $this->console->setStatus($line); diff --git a/src/Console/RiskReview.php b/src/Console/RiskReview.php index 264242e55e..f829c3caab 100644 --- a/src/Console/RiskReview.php +++ b/src/Console/RiskReview.php @@ -42,7 +42,7 @@ public function __construct( public function review(RunResult $result): int { foreach ($result->files as $file) { - if (array_filter($file->remaining, fn(Violation $v) => $v->refused) && !$this->reviewFile($file)) { + if ($file->remaining->countRefused() > 0 && !$this->reviewFile($file)) { break; } } diff --git a/src/Console/RunExecutor.php b/src/Console/RunExecutor.php new file mode 100644 index 0000000000..55818f1582 --- /dev/null +++ b/src/Console/RunExecutor.php @@ -0,0 +1,594 @@ +stdin !== null; + foreach ([ + '--format' => $run->format, + '--diff' => $check?->diff, + '--max-warnings' => $check?->maxWarnings, + '--ask-risky' => $askRisky ?: null, + '--lines' => $check?->lines ?: null, + '--before' => $check?->before, + ] as $option => $value) { + if ($formatter && $value !== null) { + throw new UsageException( + "`fix --stdin` writes the fixed code alone, so it takes no `$option`; `check --stdin` reports on the code in any format.", + $args->command, + ); + } + } + + [$factory, $resolution, $configFile] = $upgrade === null + ? $this->context->resolveProject($args, $configuration, $check->only ?? []) + : $upgrade->resolve($args, $configuration); + [$config, $root] = [$resolution->config, $resolution->root]; + $profiler = $run->profile === null ? null : new Profiler; + $fixRisky = in_array(true, $check->fixRisky ?? [], true); // a bare `--fix-risky`, the names given go to the configuration + if ($run->worker !== null) { // the parent keeps the cache; the baseline decides what is reported + $runner = $factory->createRunner( + $resolution, + strict: (bool) $run->strictRules, + cache: false, + fixRisky: $fixRisky, + baseline: !$generate, + profiler: $profiler, + ); + ($runner->warmUp)?->__invoke(); // before connecting, which is what starts the other workers + return Worker::run($run->worker, $runner, $fix, $profiler); + } + + $options = [ + 'strict' => (bool) $run->strictRules, + // a file served from the cache has nothing to measure, and its counts are those the baseline left + 'cache' => !$generate && !$profiler, + 'configFile' => $configFile, + 'fixRisky' => $fixRisky, + 'baseline' => !$generate, + 'profiler' => $profiler, + 'refreshCache' => (bool) $run->noCache, + ]; + $runner = $factory->createRunner($resolution, ...$options); + [$out, $err] = [$this->context->out, $this->context->err]; + $format = self::resolveFormat($run->format, detect: true); + // only the parent warns, a worker has returned above; an agent can do nothing about the configuration + foreach ($formatter || $format === 'agent' ? [] : $resolution->warnings as $warning => $docs) { + $err->writeLine(Markup::highlightCode($err, "Warning: $warning", 'yellow')); + if ($docs !== null) { + $err->writeLine(Markup::formatDocsLink($err, $docs)); + } + } + + $stdinPath = $check?->stdin; + $paths = array_values(array_unique(array_map($this->context->resolvePath(...), $run->paths))); + $maxWarnings = $check?->maxWarnings; + if ($askRisky && ($check?->fixRisky || is_string($stdinPath))) { + throw new UsageException( + '`--ask-risky` asks about the risky fixes one by one, so it goes with neither `--fix-risky` nor `--stdin`.', + $args->command, + ); + } elseif ($askRisky && ($format !== 'console' || !$this->context->prompt->isInteractive())) { + throw new UsageException( + '`--ask-risky` asks in an interactive terminal; without one, allow the risky fixes with `--fix-risky=`.', + $args->command, + ); + } + + $lines = $check?->lines ? new LineRanges($check->lines) : null; + $before = $check?->before; + if ($lines && $format !== 'agent') { + throw new UsageException('`--lines` narrows what the `agent` format reports, so it goes with no other format.', $args->command); + } elseif ($before !== null && ($format !== 'agent' || is_string($stdinPath))) { + throw new UsageException( + '`--before` tells the `agent` format what an edit of a file did, so it goes with no other format and no `--stdin`.', + $args->command, + ); + } elseif ($lines && $run->strictRules) { + throw new UsageException( + '`--lines` names the lines of the file as it was read, which `--strict-rules` reads again in every round.', + $args->command, + ); + } + + if (is_string($stdinPath)) { + if ($paths) { + throw new UsageException('Paths cannot be combined with `--stdin`.', $args->command); + } + + return $formatter + ? $this->runFormatter($runner, $stdinPath, (bool) $run->skipExcluded, $config->paths) + : $this->runStdin( + self::resolveFormat($run->format, detect: false), // the caller of stdin is an editor or a hook waiting for the format it asked for + (bool) $check->diff, + $runner, + $factory->registry, + $resolution, + $stdinPath, + $maxWarnings, + (bool) $run->skipExcluded, + $config->paths, + $lines, + ); + } + + $scope = $paths ? $runner->files->narrowPaths($paths, $config->paths) : $config->paths; + if (!$scope) { + throw new UsageException('No paths given and none configured.', $args->command); + } + + // the machine-readable formats must not be prefaced, and a generated baseline is not a report + $preface = !$generate && in_array($format, ['console', 'github'], true); + if ($preface) { + if ($this->context->xdebug) { + $err->writeLine('Warning: Xdebug is loaded and makes the run many times slower.', 'red'); + } + + $this->context->writeHeader($configFile, $resolution, Baseline::findFile($config, $root)); + $upgrade?->writePreface($resolution); + + $out->setStatus(Markup::formatLabel($out, $fix ? 'Fixing' : 'Checking') . 'looking for files…'); + } + + try { + $files = $runner->files->findFiles($scope, skipExcluded: (bool) $run->skipExcluded, configured: $config->paths); + } finally { + $out->clearStatus(); + } + + $files = $upgrade?->leaveOutGenerated($files, $root, $preface) ?? $files; + if ($preface) { + $this->writeScope(files: $files, paths: $scope, root: $root, fix: $fix, narrowed: $paths && $scope !== $paths); + } + + if (($lines || $before !== null) && count($files) > 1) { + throw new UsageException( + ($lines ? '`--lines` names the lines of one file' : '`--before` names the text of one file') + . ', but ' . count($files) . ' files are to be run.', + $args->command, + ); + } elseif ($before !== null && $files) { + $code = @file_get_contents($this->context->resolvePath($before)); // @ the failure is said below + if ($code === false) { + throw new UsageException("Cannot read file `$before`, which `--before` names.", $args->command); + } + + $left = $this->findBrokenDecisions($runner, $files[0], $code); + $runner = $factory->createRunner($resolution, ...$options, leftAsWritten: $left); + $lines = new LineRanges($check->lines, array_fill_keys($left, true)); + } + + $jobs = $run->jobs ?? WorkerPool::countDefaultJobs(count($files)); + $command = $jobs > 1 && $files + ? $this->buildWorkerCommand($args, $configuration, $run, $check, $upgrade?->getWorkerOptions($resolution) ?? []) + : null; + $workers = $command + ? new WorkerPool($command, $jobs, $this->context->workingDirectory, profiler: $profiler, warmFirst: $runner->warmUp !== null) + : null; + $progress = !$generate && $format === 'console' && count($files) > 1 && $out->isTerminal() + ? new ProgressBar($out, count($files)) + : null; + $onProgress = $progress === null ? null : $progress->advance(...); + + $profiler?->addPhase('start of the process', (int) ((microtime(as_float: true) - $_SERVER['REQUEST_TIME_FLOAT']) * 1e9)); + $start = hrtime(as_number: true); + try { + if ($generate) { + return $this->generateBaseline($runner, $config, $root, $configFile, $files, $workers, $format); + } + + $reporter = $this->createReporter( + (bool) $check?->diff, + $out, + $this->context->stdout, + $root, + $format, + $factory->registry, + $runner, + $resolution, + $run->paths, + $upgrade === null ? 'fix' : TodayUpgrade::Command, + ); + $result = $runner->run($files, $fix, $reporter, $workers, $onProgress, $maxWarnings, $lines); + if ($upgrade !== null && $preface && $result->countChangedFiles()) { + $out->writeLine('Run `dresscode ' . TodayUpgrade::Command . '` again: the code it rewrote may hold more to upgrade.'); + } + + return $askRisky + ? $this->review($runner, $result, $files, $root, $maxWarnings, $factory->registry) + : $result->getExitCode(); + + } finally { + $progress?->clear(); // an error must not be written into the bar + if ($profiler && $run->profile !== null) { + $profiler->addPhase('run', hrtime(as_number: true) - $start); + FileSystem::write( + $this->context->resolvePath($run->profile), + json_encode($profiler->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n", + ); + } + } + } + + + /** + * The decisions the file as it was before an edit broke, which the run then leaves as the file writes them. + * @return list + */ + private function findBrokenDecisions(Runner $runner, string $file, string $code): array + { + if ($runner->isKnownClean($file, $code)) { // the text the previous fix of the file wrote, most often + return []; + } + + $decisions = array_column($runner->processCode($file, $code)->violations, 'decision'); + return array_values(array_unique($decisions)); + } + + + /** + * Reports on the code read from stdin as if it were the file at the path; nothing of a path the excluded ones leave + * out where the caller asks to skip it. + * @param list $configured + */ + private function runStdin( + string $format, + bool $diff, + Runner $runner, + PluginRegistry $registry, + ResolvedProject $resolution, + string $path, + ?int $maxWarnings, + bool $skipExcluded, + array $configured, + ?LineRanges $lines, + ): int + { + $reporter = $this->createReporter( + $diff, + $this->context->out, + $this->context->stdout, + $resolution->root, + $format, + $registry, + $runner, + $resolution, + [$path], + ); + $code = (string) stream_get_contents($this->context->stdin); + $absolute = $this->context->resolvePath($path); + $skipped = $skipExcluded && $runner->files->excludes($absolute, $configured); + $reporter->start($runner->createRunInfo(false, $skipped ? 0 : 1)); + $result = $skipped ? null : $runner->processPath($absolute, fix: false, code: $code); + $result = $result === null ? null : $lines?->select($result, fix: false) ?? $result; + if ($result) { + $reporter->reportFile($result); + } + + $run = new RunResult($result ? [FileSummary::of($result)] : [], fix: false, maxWarnings: $maxWarnings); + $reporter->finish($run); + return $run->getExitCode(); + } + + + /** + * Formats the code read from stdin as if it were the file at the path, for an editor: the whole fixed code goes to + * stdout with exit code 0, whatever it still violates; a syntax error or a failure leaves the code as it came, its + * one line on stderr, and so does a path the excluded ones leave out, without a word. + * @param list $configured + */ + private function runFormatter(Runner $runner, string $path, bool $skipExcluded, array $configured): int + { + $code = (string) stream_get_contents($this->context->stdin); + $path = $this->context->resolvePath($path); + $result = $skipExcluded && $runner->files->excludes($path, $configured) + ? null + : $runner->processPath($path, fix: false, code: $code); + [$output, $error, $exit] = match (true) { + $result?->syntaxError !== null => [$code, "$result->path:$result->syntaxErrorLine: Syntax error: $result->syntaxError", 1], + $result?->failure !== null => [$code, "$result->path: " . strtok($result->failure, "\n"), 2], + default => [$result->output ?? $code, null, 0], + }; + + if ($error !== null) { + fwrite($this->context->stderr, "$error\n"); + } + + return fwrite($this->context->stdout, $output) === strlen($output) ? $exit : 2; + } + + + /** + * Asks about the risky fixes the fix left and makes those accepted, then says how it ended; the exit code is that of + * the files as the review left them. + * @param list $files + */ + private function review(Runner $runner, RunResult $result, array $files, string $root, ?int $maxWarnings, PluginRegistry $registry): int + { + $out = $this->context->out; + if ($result->countRefused() === 0) { + $out->writeLine("\n" . $out->color($result->getExitCode() === 0 ? 'white/green' : 'white/red', 'REVIEWED no risky fixes to ask about')); + return $result->getExitCode(); + } + + $made = new RiskReview($runner, $out, $this->context->prompt, $root, $registry)->review($result); + $final = $runner->run($files, true, new Reporters\NullReporter, maxWarnings: $maxWarnings); + $left = $final->countRefused(); + $out->writeLine( + "\n" . $out->color( + $final->getExitCode() === 0 ? 'white/green' : 'white/red', + sprintf('REVIEWED %d risky %s made, %s', $made, $made === 1 ? 'fix' : 'fixes', $left ? "$left left" : 'none left'), + ), + ); + return $final->getExitCode(); + } + + + /** + * What the rules are applied to: the scope reaches wherever the configuration was found, + * not where the run was started. + * @param list $files relative to the root, or absolute when outside it + * @param list $paths they were found under these + * @param bool $narrowed a directory named on the command line was narrowed to the configured paths + */ + private function writeScope(array $files, array $paths, string $root, bool $fix, bool $narrowed): void + { + $out = $this->context->out; + $absolute = array_map(fn(string $file) => FileSystem::isAbsolute($file) ? $file : "$root/$file", $files); + $scope = count($files) === 1 + ? FileSystem::platformSlashes($absolute[0]) + : sprintf('%d files in %s', count($files), FileSystem::platformSlashes(self::findCommonDirectory($absolute) ?: $root)); + $out->write($files + ? Markup::formatLabel($out, $fix ? 'Fixing' : 'Checking') . $scope + . ($narrowed ? $out->color('gray', ', narrowed to the configured paths') : '') . "\n\n" + : $out->color('yellow', sprintf( + ($fix ? 'Nothing to fix' : 'Nothing to check') . ': no file in %s', + implode(', ', array_map(FileSystem::platformSlashes(...), $paths)), + )) . "\n"); + } + + + /** + * The directory all the files share; that is the scope the run really has. + * @param list $files + */ + private static function findCommonDirectory(array $files): string + { + $common = null; + foreach ($files as $file) { + $segments = explode('/', $file); + array_pop($segments); + if ($common === null) { + $common = $segments; + continue; + } + + $length = 0; + while (isset($common[$length], $segments[$length]) && $common[$length] === $segments[$length]) { + $length++; + } + + $common = array_slice($common, 0, $length); + } + + return implode('/', $common ?? []); + } + + + /** + * The command line of a worker: the same PHP, the same command and configuration; the paths come over the + * connection. Null where there is no PHP to start one with, and the run stays in the process. + * @param ?CheckOptions $check null for the baseline + * @param list $extra what the parent decided for the run beyond its command line + * @return ?list + */ + private function buildWorkerCommand( + ParseResult $args, + ConfigOptions $configuration, + RunOptions $run, + ?CheckOptions $check, + array $extra = [], + ): ?array + { + $php = WorkerPool::buildPhpCommand(); + if ($php === null) { + return null; + } + + $command = [ + ...$php, + $this->context->scriptFile, + ...explode(' ', $args->command->getFullName()), + '--no-color', + ...$extra, + ...$configuration->toArguments(), + ...array_map(fn(string $only) => "--only=$only", $check->only ?? []), + ]; + + if ($run->strictRules) { + $command[] = '--strict-rules'; + } + + foreach ($check->fixRisky ?? [] as $value) { // what a worker may fix has to be what the parent was asked for + $command[] = is_string($value) ? "--fix-risky=$value" : '--fix-risky'; + } + + if ($run->profile !== null) { // a worker measures and hands it over with every file, the parent writes + $command[] = "--profile=$run->profile"; + } + + return $command; + } + + + /** + * Runs the check without the current baseline over code a fix leaves alone and writes what remains into the + * configured baseline file, or into the one of the default name the run finds by itself; code a fix would still + * change, or a file that fails or does not parse, is refused. + * @param list $files + */ + private function generateBaseline( + Runner $runner, + Config $config, + string $root, + ?string $configFile, + array $files, + ?WorkerPool $workers, + string $format, + ): int + { + $name = $config->baseline ?? Baseline::getDefaultName($configFile); + $file = Helpers::toAbsolutePath($name, $root); + $collected = new Reporters\CollectingReporter; + $run = $runner->run($files, fix: false, reporter: $collected, workers: $workers); + $changed = $failed = []; + foreach ($run->files as $result) { + if ($result->failure !== null || $result->syntaxError !== null) { + $failed[] = $result->path; + } elseif ($result->changed) { + $changed[] = $result->path; + } + } + + if ($changed || $failed) { + $counts = array_filter([ + $changed ? 'a fix would change ' . Markup::formatCount(count($changed), 'file') : null, + $failed ? Markup::formatCount(count($failed), 'file') . ' failed' : null, + ]); + $paths = [...$changed, ...$failed]; + $this->context->writeError( + 'The baseline is generated over code a fix leaves alone: ' . implode(', ', $counts) . '. Run `fix` first.', + 'suppressing#baseline', + implode('', array_map(fn(string $path) => " $path\n", array_slice($paths, 0, 10))) + . (count($paths) > 10 ? ' and ' . (count($paths) - 10) . " more\n" : ''), + ); + return 1; + } + + $baseline = Baseline::fromViolations($collected->violations); + $baseline->save($file); + $message = 'Baseline with ' . Markup::formatCount($baseline->count(), 'violation') . " written to `$name`.\n"; + // every other format keeps its stream to itself + $format === 'console' ? $this->context->write($message) : $this->context->writeNote($message); + return 0; + } + + + /** + * @param bool $diff the console shows the fixes as a unified diff + * @param resource $stream the same place as the console; a machine-readable format is no text of a console + * @param string $root the paths of the results are relative to it + * @param list $paths the paths as the command line gave them + * @param string $command the command whose `--ask-risky` decides the risky fixes left + */ + private function createReporter( + bool $diff, + Console $console, + $stream, + string $root, + string $format, + PluginRegistry $registry, + Runner $runner, + ResolvedProject $resolution, + array $paths, + string $command = 'fix', + ): Reporter + { + return match ($format) { + 'lines' => new Reporters\LinesReporter($stream, $root, Helpers::canonicalizePath($this->context->workingDirectory)), + 'agent' => new Reporters\AgentReporter( + $stream, + $root, + Helpers::canonicalizePath($this->context->workingDirectory), + givenPaths: array_combine( + array_map(fn(string $path) => Helpers::canonicalizePath($this->context->resolvePath($path)), $paths), + $paths, + ), + findSuppressionExamples: function (string $path) use ($runner, $resolution): array { + $config = $resolution->resolveFor($runner->findOverridesFor($path)); + $examples = []; + foreach ($config->suppressionComments as $pattern => $decisions) { + foreach (isset($config->suppressionExamples[$pattern]) ? $decisions : [] as $decision) { + $examples[$decision][] = $config->suppressionExamples[$pattern]; + } + } + + return $examples; + }, + ), + 'json' => new Reporters\JsonReporter($stream), + 'checkstyle' => new Reporters\CheckstyleReporter($stream), + 'github' => new Reporters\GithubReporter($stream, $root, self::findEnv('GITHUB_WORKSPACE')), + default => new Reporters\ConsoleReporter( + $console, + diff: $diff, + root: $root, + cwd: Helpers::canonicalizePath($this->context->workingDirectory), + findDecisionUrl: $registry->findUrl(...), + command: $command, + ), + }; + } + + + /** + * The format asked for, or the one the surroundings call for: annotations when the run is a step + * of a GitHub Actions workflow, where nobody reads the log. + * @param bool $detect let the surroundings decide when the command line does not + */ + private static function resolveFormat(?string $format, bool $detect): string + { + return $format ?? ($detect && self::findEnv('GITHUB_ACTIONS') === 'true' ? 'github' : 'console'); + } + + + private static function findEnv(string $name): ?string + { + $value = getenv($name); + return $value === false || $value === '' ? null : $value; + } +} diff --git a/src/Console/RunOutcomePrinter.php b/src/Console/RunOutcomePrinter.php new file mode 100644 index 0000000000..8021d4b2df --- /dev/null +++ b/src/Console/RunOutcomePrinter.php @@ -0,0 +1,200 @@ +outcome; + $out = ''; + foreach ($outcome->applied as $number => $topics) { + $out .= Markup::formatLabel($console, "Step $number") . implode(', ', array_map( + fn(string $topic, array $files) => "$topic " + . ($files === [] ? 'left nothing to fix' : Markup::formatCount(count($files), 'file')), + array_keys($topics), + $topics, + )) . "\n"; + } + + $out .= new TasksPrinter($outcome->tasks, $outcome->postponed, $outcome->unprocessed)->print($console); + if ($baseline = $outcome->baseline) { + // a test failing before the first step is no regression, so passing the comparison is not passing the tests + $out .= Markup::formatLabel($console, 'Tests') . 'compared with their run before the first step' + . ($baseline->time === null ? '' : ', ' . date('Y-m-d H:i', $baseline->time)) + . ', where ' . $baseline->countNotPassed() . ' of ' . count($baseline->tests) . " did not pass\n"; + } + + foreach ($outcome->warnings as $warning) { + $out .= Markup::formatLabel($console, 'Warning') . "$warning\n"; + } + + if ($outcome->raisedFiles !== []) { + $out .= Markup::formatLabel($console, 'Raised') . implode(', ', $outcome->raisedFiles) + . ' changed, which `dresscode upgrade abort` puts back as they were before the raise and installs' . "\n"; + } + + return $out . match ($outcome->stop) { + RunStop::Tests => Markup::formatLabel($console, 'Failed') . match (true) { + $outcome->step === null => "$outcome->crash, so nothing was changed", + $this->plan->steps[$outcome->step - 1] instanceof DependencyStep + => "step $outcome->step raised the dependencies, which stay raised, and ", + default => "step $outcome->step went back, its fixes undone and the dependencies as the steps before left them, as ", + } . match (true) { + $outcome->step === null => '', + $outcome->newFailures === [] => $outcome->crash ?? 'the tests wrote no results after it', + default => 'these tests now fail: ' . implode(', ', $outcome->newFailures), + }, + RunStop::Commit => Markup::formatLabel($console, 'Failed') . "step $outcome->step, $outcome->commitFailure", + RunStop::Composer => $outcome->composerFailure === null + ? Markup::formatLabel($console, 'Waiting') . "step $outcome->step, {$this->formatComposerStep((int) $outcome->step)}" + : Markup::formatLabel($console, 'Failed') + . "step $outcome->step, $outcome->composerFailure; {$this->formatComposerStep((int) $outcome->step)}", + RunStop::Raised => Markup::formatLabel($console, 'Waiting') + . "step $outcome->step raised the dependencies, which what runs after it loads anew: run `dresscode upgrade run` again", + RunStop::Manual + => Markup::formatLabel($console, 'Waiting') . "step $outcome->step, {$this->formatManualStep((int) $outcome->step)}", + RunStop::Interrupted => Markup::formatLabel($console, 'Failed') . "step $outcome->step did not finish" + . ($outcome->interrupted === [] + ? ' and may have rewritten files its snapshots no longer tell, which `git status` and `git diff` show' + : ', and may have rewritten ' . implode(', ', $outcome->interrupted) + . ': `dresscode upgrade abort` puts them back as they were before it') + . ', or go on from the code as it is with `dresscode upgrade run --discard`', + RunStop::Tasks => Markup::formatLabel($console, 'Waiting') + . ($outcome->step === null ? 'the plan leaves' : "step $outcome->step leaves") . ' the manual tasks above: fix each or accept it with ' + . '`// dresscode:ignore -- `, then run `dresscode upgrade run`', + RunStop::Unprocessed => Markup::formatLabel($console, 'Waiting') + . "step $outcome->step did not fix the files above: make each parse, or leave it out of the paths, then run `dresscode upgrade run`", + RunStop::Remaining => Markup::formatLabel($console, 'Waiting') + . 'the targets still find what no step of the plan fixed' + . ($outcome->remaining === [] + ? ', listed above' + : ', which `dresscode upgrade today` fixes in ' . implode(', ', $outcome->remaining)) + . ', then run `dresscode upgrade run`', + RunStop::Done => $console->color('white/green', $this->singleStep === null + ? 'UPGRADED ' . $this->formatDoneSteps() . ', and the final check finds nothing more to upgrade' + . (($this->plan->testCommand ?? $this->plan->quickTestCommand) === null ? ', no tests run' : '') + . ($this->plan->missingData === [] + ? '' + : ', though without the upgrading data of ' + . implode(', ', array_keys($this->plan->missingData)) + . ', which the estimate lacked') + : "DONE step $this->singleStep") + . ($this->singleStep === null && $this->leavesLowest() + ? "\nA raise of the plan installs the lowest versions the constraints allow, which the code is written for; `composer update` installs the newest." + : ''), + } . "\n"; + } + + + /** + * The run as the data the format `json` writes beside the plan. + * @return array + */ + public function toArray(): array + { + $outcome = $this->outcome; + $applied = []; + foreach ($outcome->applied as $step => $topics) { + foreach ($topics as $topic => $files) { + $applied[] = ['step' => $step, 'topic' => $topic, 'files' => $files]; + } + } + + $found = new TasksPrinter($outcome->tasks, $outcome->postponed, $outcome->unprocessed)->toArray(); + return [ + 'stop' => lcfirst($outcome->stop->name), + 'step' => $outcome->step, + 'applied' => $applied, + 'tasks' => $found['tasks'], + 'exceptions' => $found['exceptions'], + 'newFailures' => $outcome->newFailures, + 'crash' => $outcome->crash, + 'baseline' => $outcome->baseline === null ? null : [ + 'time' => $outcome->baseline->time === null ? null : date(DATE_ATOM, $outcome->baseline->time), + 'tests' => count($outcome->baseline->tests), + 'notPassed' => $outcome->baseline->countNotPassed(), + ], + 'unverified' => $found['unverified'], + 'blockers' => $outcome->blockers === null ? null : [ + 'packages' => array_map(fn(Blocker $blocker) => (array) $blocker, $outcome->blockers->packages), + 'unknown' => $outcome->blockers->unknown, + ], + 'interrupted' => $outcome->interrupted, + 'raisedFiles' => $outcome->raisedFiles, + 'remaining' => $outcome->remaining, + 'warnings' => $outcome->warnings, + 'composerFailure' => $outcome->composerFailure, + 'commitFailure' => $outcome->commitFailure, + ]; + } + + + /** The steps of the plan that ran and those it skips: "3 steps done, 1 skipped as the plan says". */ + private function formatDoneSteps(): string + { + $done = count($this->plan->findSteps()); + $skipped = count($this->plan->steps) - $done; + return Markup::formatCount($done, 'step') . ' done' . ($skipped ? ", $skipped skipped as the plan says" : ''); + } + + + /** + * Whether a step of the plan updates the packages, which it leaves at the lowest versions the constraints allow, and + * no step of it updates them to the newest then. + */ + private function leavesLowest(): bool + { + $steps = $this->plan->findSteps(); + return array_any($steps, fn(Step $step) => $step instanceof ComposerStep && $step->update) + && !array_any($steps, fn(Step $step) => $step instanceof UpdateStep); + } + + + private function formatComposerStep(int $number): string + { + $step = $this->plan->steps[$number - 1]; + assert($step instanceof DependencyStep); + return "$step->title: run `" . implode('` and `', $step->formatCommands()) . '`, then `dresscode upgrade run`'; + } + + + private function formatManualStep(int $number): string + { + $step = $this->plan->steps[$number - 1]; + assert($step instanceof ManualStep && $this->outcome->blockers !== null); + return "make way for {$step->target->format()}, " . EstimatePrinter::formatBlockers($this->outcome->blockers) + . ($this->outcome->blockers->unknown === null + ? ": update or replace what refuses it, as `{$step->formatCommand()}` tells" + : ': where you know nothing refuses it, set `skip: true` on the step in `' . PlanFile::Name . '`') + . ', then run `dresscode upgrade run`'; + } +} diff --git a/src/Console/StatusLine.php b/src/Console/StatusLine.php new file mode 100644 index 0000000000..4eae6fb3c8 --- /dev/null +++ b/src/Console/StatusLine.php @@ -0,0 +1,76 @@ +active) { + return; + } + + $this->phase = Markup::formatLabel($this->console, $label) . $text . '…'; + $this->console->setStatus($this->shown = $this->phase); + } + + + /** Turns the spinner beside the phase, with the steps done and all of them where there is more than one. */ + public function advance(int $done, int $all): void + { + if (!$this->active) { + return; + } + + $line = $this->phase . ' ' . self::formatSpinner($this->console, $all > 1 ? "$done/$all" : ''); + if ($line !== $this->shown) { + $this->console->setStatus($this->shown = $line); + } + } + + + /** The frame of the spinner for this moment, with the progress beside it in the same color. */ + public static function formatSpinner(Console $console, string $progress = ''): string + { + $frame = self::SpinnerFrames[(int) (microtime(as_float: true) * self::SpinnerSpeed) % count(self::SpinnerFrames)]; + return $console->color(self::SpinnerColor, $frame . ($progress === '' ? '' : " $progress")); + } +} diff --git a/src/Console/StatusPrinter.php b/src/Console/StatusPrinter.php new file mode 100644 index 0000000000..83473ed097 --- /dev/null +++ b/src/Console/StatusPrinter.php @@ -0,0 +1,104 @@ +status; + $out = Markup::formatLabel($console, 'Plan') + . implode(', ', array_map(fn(Target $target) => $target->format(), $this->plan->targets)) . "\n"; + $width = max([0, ...array_map(fn(Step $step) => strlen($step->title), $this->plan->steps)]); + foreach ($this->plan->steps as $index => $step) { + $out .= sprintf(' %2d %s %s', $index + 1, str_pad($step->title, $width), match ($status->steps[$index + 1]) { + StepStatus::Done => $console->color('green', 'done'), + StepStatus::Due => $console->color('yellow', 'due'), + StepStatus::Skipped => $console->color('gray', 'skipped'), + StepStatus::Unknown => $console->color('gray', $step instanceof FixStep + ? 'what is left `upgrade run` fixes and `upgrade tasks` lists' + : '`upgrade run` tells'), + }) . "\n"; + } + + foreach ($status->interrupted as $step => $files) { + $out .= Markup::formatLabel($console, 'Stopped') + . "step $step did not finish and may have rewritten " + . implode(', ', $files) + . "\n"; + } + + if ($status->raisedFiles !== []) { + $out .= Markup::formatLabel($console, 'Raised') . "step $status->raisedStep changed " + . implode(', ', $status->raisedFiles) . ", which `dresscode upgrade abort` puts back\n"; + } + + if ($status->awaitingInstall) { + $out .= Markup::formatLabel($console, 'Install') + . 'the files of Composer are put back, but not the packages yet, which `dresscode upgrade abort` installs' + . "\n"; + } + + if ($status->uncommittedStep !== null) { + $out .= Markup::formatLabel($console, 'Commit') + . "step $status->uncommittedStep is not committed yet, which the next `upgrade run --commit` does\n"; + } + + foreach ($status->baselines as $command => $time) { + $out .= Markup::formatLabel($console, 'Tests') . Violation::formatCode($command) . ($time === null + ? ' has no run before the first step yet, which the next `upgrade run` takes' + : ' is compared with its run of ' . date('Y-m-d H:i', $time)) . "\n"; + } + + return $out . Markup::formatLabel($console, 'Next') . ($status->interrupted === [] + ? '`dresscode upgrade run` carries on from where the code stands, and ends at once where nothing is left' + : '`dresscode upgrade abort --dry-run` shows what `upgrade abort` puts back, or `dresscode upgrade run --discard` goes on from the code as it is') + . "\n"; + } + + + /** + * Where the plan stands as the data the format `json` writes. + * @return array + */ + public function toArray(): array + { + $status = $this->status; + return [ + 'version' => self::Version, + 'steps' => array_map(fn(StepStatus $step) => lcfirst($step->name), $status->steps), + 'interrupted' => (object) $status->interrupted, + 'raisedStep' => $status->raisedStep, + 'raisedFiles' => $status->raisedFiles, + 'awaitingInstall' => $status->awaitingInstall, + 'uncommittedStep' => $status->uncommittedStep, + 'baselines' => (object) array_map(fn(?int $time) => $time === null ? null : date(DATE_ATOM, $time), $status->baselines), + ]; + } +} diff --git a/src/Console/TasksPrinter.php b/src/Console/TasksPrinter.php new file mode 100644 index 0000000000..b3cbb6eea0 --- /dev/null +++ b/src/Console/TasksPrinter.php @@ -0,0 +1,133 @@ +> topic => its tasks */ + private array $tasks, + /** @var list */ + private array $postponed = [], + /** @var array the files the fixes could not process, relative to the root => why */ + private array $unprocessed = [], + ) { + } + + + public function print(Console $console): string + { + $tasks = array_merge(...array_values($this->tasks)); + $out = ''; + if ($tasks !== []) { + $out .= Markup::formatLabel($console, 'Manual') . Markup::formatCount(count($tasks), 'task') . " the fixes left\n"; + foreach ($tasks as $task) { + $violation = $task->violation; + $out .= " $task->path:$violation->line $violation->message " . $console->color('gray', "[$violation->decision]") . "\n" + . ($violation->risk === null ? '' : ' ' . $console->color('gray', self::formatRisk($violation)) . "\n"); + } + } + + if ($this->postponed !== []) { + $out .= Markup::formatLabel($console, 'Exceptions') . Markup::formatCount(count($this->postponed), 'suppression') + . " giving no reason, so not accepted yet: accept each with ` -- `\n"; + foreach ($this->postponed as $postponement) { + $out .= " $postponement->path:$postponement->line $postponement->comment\n"; + } + } + + if ($this->unprocessed !== []) { + $out .= Markup::formatLabel($console, 'Unverified') . Markup::formatCount(count($this->unprocessed), 'file') + . " the fixes could not process, left as they are\n"; + foreach ($this->unprocessed as $path => $why) { + $out .= " $path " . strtok($why, "\n") . "\n"; + } + } + + return $out; + } + + + /** What the fix of a risky place waits for, and how to give it. */ + private static function formatRisk(Violation $violation): string + { + return match ($violation->risk) { + Risk::TypeUnknown => 'its fix needs the type of the expression, which the analysis does not know; where the types are off, `--types` turns them on', + Risk::NameUncertain => 'its fix needs to know which function the name calls, which a declaration elsewhere could change; fix it by hand, or list what the namespaces declare under `namespaces` with `nameResolution: certain`', + default => 'its fix is risky' . ($violation->because === null ? '' : ", as $violation->because") + . "; once you checked it, `fixRisky: [$violation->decision]` allows it", + }; + } + + + /** + * The tasks as the data the format `json` writes. + * @return array{ + * tasks: list>, + * exceptions: list, + * unverified: list, + * } + */ + public function toArray(): array + { + $tasks = []; + foreach ($this->tasks as $topic => $topicTasks) { + foreach ($topicTasks as $task) { + $tasks[] = [ + 'topic' => $topic, + 'file' => $task->path, + 'line' => $task->violation->line, + 'decision' => $task->violation->decision, + 'message' => $task->violation->message, + 'fixable' => $task->violation->fixable, + 'risk' => $task->violation->risk?->value, + 'because' => $task->violation->because, + ]; + } + } + + return [ + 'tasks' => $tasks, + 'exceptions' => array_map( + fn(Postponement $postponement) => [ + 'file' => $postponement->path, + 'line' => $postponement->line, + 'comment' => $postponement->comment, + ], + $this->postponed, + ), + 'unverified' => self::listUnverified($this->unprocessed), + ]; + } + + + /** + * The files a check could not process as the JSON documents write them. + * @param array $unprocessed file => why + * @return list + */ + public static function listUnverified(array $unprocessed): array + { + return array_map(fn(string $path, string $why) => ['file' => $path, 'message' => $why], array_keys($unprocessed), $unprocessed); + } +} diff --git a/src/Engine/Baseline.php b/src/Engine/Baseline.php index ac82f1dbe6..ce37b3088b 100644 --- a/src/Engine/Baseline.php +++ b/src/Engine/Baseline.php @@ -7,7 +7,8 @@ namespace DressCode\Engine; -use DressCode\{ConfigurationException, FileResult}; +use DressCode\{Config, ConfigurationException, Violation}; +use DressCode\Config\Loader; use Nette\Neon\{Exception as NeonException, Neon}; use function is_array, is_string; @@ -19,6 +20,9 @@ */ final class Baseline { + /** the name of the file a run finds by itself, with the extension of the format of the configuration */ + private const DefaultName = 'dresscode-baseline'; + /** the version of the file, a new one where the fingerprints are made otherwise */ private const Version = 2; @@ -32,14 +36,49 @@ final class Baseline /** - * The baseline of the file, or null before the first generation; the name is judged either way. + * The baseline the run holds violations back by, which must exist where the configuration names it. + * @throws ConfigurationException + */ + public static function open(Config $config, string $root): ?self + { + $file = self::findFile($config, $root); + return $file === null ? null : self::load($file, required: $config->baseline !== null); + } + + + /** + * The baseline file the configuration names, else the one of the default name in the root where there is one; null + * before the first `dresscode baseline`. + */ + public static function findFile(Config $config, string $root): ?string + { + if ($config->baseline !== null) { + return Helpers::toAbsolutePath($config->baseline, $root); + } + + $files = array_map(fn(string $format) => "$root/" . self::DefaultName . ".$format", ['neon', 'php']); + return array_find($files, fn(string $file) => is_file($file)); + } + + + /** The name a baseline the configuration names none is written under, in the format of the configuration. */ + public static function getDefaultName(?string $configFile): string + { + return self::DefaultName . '.' . (Loader::detectFormat($configFile ?? '') ?? 'neon'); + } + + + /** + * The baseline of the file, or null before the first generation unless it is required; the name is judged either way. * @throws ConfigurationException */ - public static function load(string $file): ?self + public static function load(string $file, bool $required = false): ?self { $format = self::resolveFormat($file); if (!is_file($file)) { - return null; + return $required + ? throw new ConfigurationException("Baseline file `$file` does not exist; `dresscode baseline` writes it.") + : null; } try { @@ -81,15 +120,15 @@ public static function load(string $file): ?self } - /** @param list $results */ - public static function fromResults(array $results): self + /** @param array> $violations the path => what the file violates */ + public static function fromViolations(array $violations): self { $baseline = new self; - foreach ($results as $result) { - foreach ($result->violations as $violation) { + foreach ($violations as $path => $list) { + foreach ($list as $violation) { // a refused risky fix that is only uncertain waits for what the project can tell, not for the baseline if (!$violation->refused || !$violation->risk?->isResolvable()) { - $baseline->add($result->path, $violation->fingerprint, $violation->decision, $violation->message); + $baseline->add($path, $violation->fingerprint, $violation->decision, $violation->message); } } } diff --git a/src/Engine/CycleCollector.php b/src/Engine/CycleCollector.php index bbe3852a15..fdf62b337a 100644 --- a/src/Engine/CycleCollector.php +++ b/src/Engine/CycleCollector.php @@ -24,8 +24,9 @@ final class CycleCollector private int $threshold = self::Floor; - public function __construct() - { + public function __construct( + private readonly ?Profiler $profiler = null, + ) { $this->enabled = gc_enabled(); gc_disable(); } @@ -38,7 +39,9 @@ public function collect(): void return; } + $start = $this->profiler ? hrtime(as_number: true) : 0; gc_collect_cycles(); + $this->profiler?->addPhase('gc', hrtime(as_number: true) - $start); $this->threshold = max(self::Floor, min(2 * memory_get_usage(), self::findCeiling())); } diff --git a/src/Engine/FileFinder.php b/src/Engine/FileFinder.php new file mode 100644 index 0000000000..8aa98b60aa --- /dev/null +++ b/src/Engine/FileFinder.php @@ -0,0 +1,149 @@ + patterns of paths left out */ + private array $excludePaths = [], + /** @var list */ + private array $fileExtensions = ['php'], + ) { + $this->root = Helpers::canonicalizePath($root); + } + + + /** + * Files under the paths with one of the extensions, minus the excluded ones; an explicitly given file + * is taken as is, unless `$skipExcluded` lets the excluded paths leave it out like a found one, and so the + * configured paths where they are given, which is what a hook or an editor naming every file it touches wants; + * a file outside the root has no path the patterns could match. Sorted, relative to the root. + * @param list $paths + * @param list $configured the configured paths, outside which `$skipExcluded` leaves a named file out + * @return list + * @throws ConfigurationException for a path that does not exist + */ + public function findFiles(array $paths, bool $skipExcluded = false, array $configured = []): array + { + $files = []; + foreach ($paths as $path) { + $path = $this->relativize($path); + $absolute = $this->toAbsolute($path); + if (is_file($absolute)) { + if (!$skipExcluded || !$this->excludes($path, $configured)) { + $files[$path] = true; + } + } elseif (is_dir($absolute)) { + $finder = Finder::findFiles(array_map(fn($ext) => "*.$ext", $this->fileExtensions)) + ->from($absolute) + ->descentFilter( + fn(\SplFileInfo $dir) => !Helpers::matchesAny($this->excludePaths, $this->relativize($dir->getPathname())), + ); + foreach ($finder as $file) { + $relative = $this->relativize($file->getPathname()); + if (!Helpers::matchesAny($this->excludePaths, $relative)) { + $files[$relative] = true; + } + } + } else { + throw new ConfigurationException("Path `$path` does not exist."); + } + } + + $files = array_keys($files); + sort($files, SORT_STRING); + return $files; + } + + + /** + * Whether the excluded paths leave out a file named by its path, or the configured paths do not hold it; a file + * outside the root has no path the patterns could match, so only configured paths leave it out. + * @param list $configured + */ + public function excludes(string $path, array $configured = []): bool + { + $path = $this->relativize($path); + $configured = array_map($this->relativize(...), $configured); + if (FileSystem::isAbsolute($path)) { + return $configured !== []; + } + + return Helpers::matchesAny($this->excludePaths, $path) || ($configured !== [] && !array_any( + $configured, + fn(string $inner) => $inner === '' || $path === $inner || str_starts_with($path, "$inner/"), + )); + } + + + /** + * The paths named within the configured ones: a directory holding some of those stands for them, anything else + * for itself, since whoever names a path outside them means it. + * @param list $paths + * @param list $configured + * @return list + */ + public function narrowPaths(array $paths, array $configured): array + { + $configured = array_map($this->relativize(...), $configured); + $narrowed = []; + foreach ($paths as $path) { + $relative = $this->relativize($path); + $held = in_array($relative, $configured, true) + ? [] + : array_filter($configured, fn(string $inner) => $relative === '' || str_starts_with("$inner/", "$relative/")); + array_push($narrowed, ...($held ?: [$path])); + } + + return array_values(array_unique($narrowed)); + } + + + /** + * A path outside the root stays absolute, a relative one is under the root. + */ + public function toAbsolute(string $path): string + { + return match (true) { + $path === '' => $this->root, + FileSystem::isAbsolute($path) => $path, + default => $this->root . '/' . $path, + }; + } + + + /** + * Path with slashes, relative to the root when it lies under it. + */ + public function relativize(string $path): string + { + $path = Helpers::canonicalizePath($path); + if ($path === $this->root) { + return ''; + } elseif (str_starts_with($path, $this->root . '/')) { + $path = substr($path, strlen($this->root) + 1); + } + + return implode('/', array_filter(explode('/', $path), fn(string $segment) => $segment !== '.')); // "./a" and "." would match the ".*" exclusion + } +} diff --git a/src/Engine/FileSummary.php b/src/Engine/FileSummary.php index 18c5d4c410..3f4a1f448d 100644 --- a/src/Engine/FileSummary.php +++ b/src/Engine/FileSummary.php @@ -7,22 +7,20 @@ namespace DressCode\Engine; -use DressCode\{FileResult, Violation}; +use DressCode\FileResult; /** - * What a run keeps of a file once it was reported: the result without its texts, so that a large tree does not stay - * in memory. + * What a run keeps of a file once it was reported: the result without its texts and with its violations only counted, + * so that a large tree does not stay in memory. * @internal */ final readonly class FileSummary { public function __construct( public string $path, - /** @var list */ - public array $violations, - /** @var list */ - public array $remaining, + public ViolationTally $violations, + public ViolationTally $remaining, /** the output differs from the code */ public bool $changed, public bool $written, @@ -36,8 +34,14 @@ public function __construct( public static function of(FileResult $result): self { return new self( - $result->path, $result->violations, $result->remaining, $result->changed, - $result->written, $result->cached, $result->syntaxError, $result->failure, + $result->path, + ViolationTally::of($result->violations), + ViolationTally::of($result->remaining), + $result->changed, + $result->written, + $result->cached, + $result->syntaxError, + $result->failure, ); } } diff --git a/src/Engine/Gaps/Claims.php b/src/Engine/Gaps/Claims.php index 0a3064da4b..0f02d3c9d3 100644 --- a/src/Engine/Gaps/Claims.php +++ b/src/Engine/Gaps/Claims.php @@ -8,6 +8,7 @@ namespace DressCode\Engine\Gaps; use DressCode\{Claim, ConfigurationException, Gap, GapRule, Rule}; +use DressCode\Engine\Profiler; use PhpSyntax\{LayoutData, Node}; use PhpSyntax\Nodes\{PlainNodeList, SeparatedNodeList}; use function sprintf, strlen; @@ -49,6 +50,7 @@ final class Claims */ public function __construct( array $rules, + private readonly ?Profiler $profiler = null, ) { foreach ($rules as $rule) { if (!$rule instanceof GapRule) { @@ -172,10 +174,31 @@ private function claim(array &$side, string $name, string $key, Rule $rule, Clai } } + if ($claim instanceof \Closure && $this->profiler) { + $claim = self::measureClaim($claim, $rule::class, $this->profiler); + } + $side[$key][] = [$rule, $claim, $key]; } + /** + * @param \Closure(Gap): ?Claim $claim + * @return \Closure(Gap): ?Claim + */ + private static function measureClaim(\Closure $claim, string $rule, Profiler $profiler): \Closure + { + return static function (Gap $gap) use ($claim, $rule, $profiler): ?Claim { + $start = hrtime(as_number: true); + try { + return $claim($gap); + } finally { + $profiler->addClaim($rule, hrtime(as_number: true) - $start); + } + }; + } + + /** @throws ConfigurationException */ public static function refuseSecond(Rule $first, Rule $second, string $side, string $key): never { diff --git a/src/Engine/Helpers.php b/src/Engine/Helpers.php index 7fca1b0d8a..56633a1a99 100644 --- a/src/Engine/Helpers.php +++ b/src/Engine/Helpers.php @@ -7,6 +7,7 @@ namespace DressCode\Engine; +use Nette\IOException; use Nette\Utils\FileSystem; use function ini_get, is_array; @@ -153,4 +154,57 @@ public static function writeFile(string $file, string $content): bool return @file_put_contents($file, $content) !== false; // @ the failure is the result } + + + /** + * Writes through a temporary file of its own renamed over the target, so that the file is either as it was or whole, + * with no fallback in place; the directory is created where it is missing. + * @throws IOException + */ + public static function replaceFile(string $file, string $content): void + { + $temp = self::writeTemporary($file, $content); + // Windows refuses a rename over a file another process holds open, as a scanner does for a moment after a write + for ($attempt = 1; !@rename($temp, $file); $attempt++) { // @ the failure is thrown below + if ($attempt === 10) { + @unlink($temp); // @ nothing to clean up then + throw new IOException("Unable to write file '$file'."); + } + + usleep(20_000); + } + } + + + /** + * Writes a file that does not exist yet, whole, and never over one another process created meanwhile. + * @return bool false where the file exists + * @throws IOException + */ + public static function createFile(string $file, string $content): bool + { + $temp = self::writeTemporary($file, $content); + $handle = @fopen($file, 'x'); // @ an existing file is the answer + if ($handle === false) { + @unlink($temp); // @ nothing to clean up then + return file_exists($file) ? false : throw new IOException("Unable to create file '$file'."); + } + + fclose($handle); + if (!@rename($temp, $file)) { // @ the failure is thrown below + @unlink($temp); // @ nothing to clean up then + @unlink($file); // @ the name reserved above + throw new IOException("Unable to create file '$file'."); + } + + return true; + } + + + private static function writeTemporary(string $file, string $content): string + { + $temp = $file . '.' . bin2hex(random_bytes(4)) . '.tmp'; + FileSystem::write($temp, $content); + return $temp; + } } diff --git a/src/Engine/PassLoop.php b/src/Engine/PassLoop.php index f2b9fcec7c..0e3c241628 100644 --- a/src/Engine/PassLoop.php +++ b/src/Engine/PassLoop.php @@ -76,6 +76,7 @@ public function __construct( private readonly RulePlan $plan, private readonly Analyses\Registry $analyses, private readonly ReportPolicy $policy = new ReportPolicy, + private readonly ?Profiler $profiler = null, ) { $this->gaps = new Resolver($plan->claims); } @@ -139,7 +140,9 @@ public function run(FileNode $file, string $code, string $path, Style $style, st $context->passStorage = []; } + $start = $this->profiler ? hrtime(as_number: true) : 0; $this->analyses->beginPass($file); + $this->profiler?->addPhase('analyses: begin pass', hrtime(as_number: true) - $start); $revision = $file->revision; foreach ($this->plan->stages as $stage => $rules) { $this->runStage($stage, $rules, $style); @@ -158,7 +161,9 @@ public function run(FileNode $file, string $code, string $path, Style $style, st $mutated = true; $mutatedRules += $this->mutatedRules; + $start = $this->profiler ? hrtime(as_number: true) : 0; $output = Printer::print($file); + $this->profiler?->addPhase('print', hrtime(as_number: true) - $start); $hash = hash('xxh3', $output); if (isset($seen[$hash])) { // a state seen before: the rules cycle throw new ConvergenceException($path, array_keys($this->mutatedRules), Diff::unified($last, $output, $path)); @@ -261,6 +266,7 @@ private static function createViolation( offset: $span[0] ?? null, length: $span[1] ?? null, originalLines: $originalLines, + source: $report->source, ); } @@ -353,8 +359,10 @@ static function (string $class, Rule $rule) use ($analyses, $file, $path, $stric return; } + $start = $this->profiler ? hrtime(as_number: true) : 0; try { $node instanceof Token ? $gaps->enterToken($node) : $gaps->enterNode($node); + $this->profiler?->addPhase('gaps', hrtime(as_number: true) - $start); } catch (ConfigurationException|RuleException $e) { // two claims deciding one gap, or a claim of the rule named failing throw $e; } catch (\Throwable $e) { @@ -394,14 +402,17 @@ private function visit(string $stage, Node|Token $node, bool $enter): void $parent = $node->parent; $file = $this->file; + $profiler = $this->profiler; foreach ($rules as [$rule, $context, $ruleClass]) { $before = $file->revision; + $start = $profiler ? hrtime(as_number: true) : 0; try { $enter ? $rule->enter($node, $context) : $rule->leave($node, $context); } catch (\Throwable $e) { throw new RuleException($ruleClass, $this->path, $e); } + $profiler?->addRule($ruleClass, hrtime(as_number: true) - $start, $file->revision !== $before); if ($file->revision !== $before || $context->hasReports()) { $this->account($ruleClass, $context, $before); } @@ -444,12 +455,14 @@ private function invoke(NodeRule $rule, \Closure $callback): void $ruleClass = $rule::class; $context = $this->contexts[$ruleClass]; $before = $this->file->revision; + $start = $this->profiler ? hrtime(as_number: true) : 0; try { $callback($context); } catch (\Throwable $e) { throw new RuleException($ruleClass, $this->path, $e); } + $this->profiler?->addRule($ruleClass, hrtime(as_number: true) - $start, $this->file->revision !== $before); $this->account($ruleClass, $context, $before); } diff --git a/src/Engine/PhpFileProcessor.php b/src/Engine/PhpFileProcessor.php index c7ddd4c170..c1f5a7e554 100644 --- a/src/Engine/PhpFileProcessor.php +++ b/src/Engine/PhpFileProcessor.php @@ -11,6 +11,7 @@ use PhpSyntax\Nodes\FileNode; use PhpSyntax\Nodes\Statement\{NamespaceNode, UseNode}; use PhpSyntax\{ParseException, Parser, SymbolKind}; +use function strlen; /** @@ -36,11 +37,12 @@ public function __construct( /** the line ending of the style follows the prevailing one of each file */ private bool $detectLineEnding = true, private ReportPolicy $policy = new ReportPolicy, + private ?Profiler $profiler = null, /** @var array the class of a rule => the gate of its reports */ private array $gates = [], ) { $this->parser = new Parser; - $this->plan = new RulePlan($this->rules, $this->gates); + $this->plan = new RulePlan($this->rules, $this->profiler, $this->gates); } @@ -74,15 +76,18 @@ public function process(string $path, string $code, array $acceptedRisks = []): $settled = false; $remaining = null; $removedImports = $mutatedRules = []; + $start = $this->profiler ? hrtime(as_number: true) : 0; + $times = ['parse' => 0, 'passes' => 0]; // a mutated tree is not the tree the parser would build from the printed text, so a rule can miss what another // one has just written; the strict run makes the text settle in rounds, the others take what the last pass // of the tree left for what a round over the text would report, which spares a pass; they only parse the printed // text, so that a broken rule never writes code PHP refuses - $passLoop = new PassLoop($this->plan, $this->analyses, $this->policy); + $passLoop = new PassLoop($this->plan, $this->analyses, $this->policy, $this->profiler); $rounds = 0; while (!$settled) { $rounds++; + $lap = $this->profiler ? hrtime(as_number: true) : 0; try { $file = $this->parser->parse($text); self::checkImports($file); @@ -90,6 +95,11 @@ public function process(string $path, string $code, array $acceptedRisks = []): return self::createSyntaxError($path, $code, $e, $rounds); } + if ($this->profiler) { + $times['parse'] += hrtime(as_number: true) - $lap; + $lap = hrtime(as_number: true); + } + try { $result = $passLoop->run($file, $text, $path, $style, $this->phpVersion, $acceptedRisks); } catch (RuleException $e) { @@ -104,6 +114,10 @@ public function process(string $path, string $code, array $acceptedRisks = []): $passes += $result->passes; array_push($removedImports, ...$result->removedImports); array_push($mutatedRules, ...$result->mutatedRules); + if ($this->profiler) { + $times['passes'] += hrtime(as_number: true) - $lap; + } + $printed = $result->output ?? $text; $settled = $printed === $text; $seen[hash('xxh3', $text)] = true; @@ -114,17 +128,38 @@ public function process(string $path, string $code, array $acceptedRisks = []): $text = $printed; if (!$settled && !$this->policy->strict && $result->remaining !== null) { + $lap = $this->profiler ? hrtime(as_number: true) : 0; try { self::checkImports($this->parser->parse($text)); } catch (ParseException $e) { return self::createParseFailure($path, $code, $e); } + if ($this->profiler) { + $times['parse'] += hrtime(as_number: true) - $lap; + } + $remaining = $result->remaining; + $rounds++; // the parse of the printed text stands for the round over it break; } } + if ($this->profiler) { + foreach ($times as $phase => $time) { + $this->profiler->addPhase($phase, $time, $rounds); + } + + $this->profiler->addFile([ + 'path' => $path, + 'size' => strlen($code), + 'time' => hrtime(as_number: true) - $start, + ...$times, + 'rounds' => $rounds, + 'passCount' => $passes, + ]); + } + // the last round is a check of the text the fix writes; a round after the first parsed that text, not the file as read $remaining ??= $result === $first ? $result->violations diff --git a/src/Engine/ProcessRunner.php b/src/Engine/ProcessRunner.php new file mode 100644 index 0000000000..960c0afa09 --- /dev/null +++ b/src/Engine/ProcessRunner.php @@ -0,0 +1,100 @@ + $command a command line, or its program and arguments to quote + * @param ?string $input what the command reads on its standard input + * @param array $env the variables set beside those of this process + * @return array{?int, string} + */ + public static function run( + string|array $command, + string $cwd, + int $timeout, + ?string $input = null, + bool $errors = true, + array $env = [], + ): array + { + // the shell of Windows is what finds `composer.bat` + $command = is_string($command) || PHP_OS_FAMILY !== 'Windows' + ? $command + : implode(' ', array_map(escapeshellarg(...), $command)); + // files rather than pipes, which Windows reads only blocking, so that a command holding them open still ends in time + [$stdin, $stdout, $stderr] = [tmpfile(), tmpfile(), $errors ? null : tmpfile()]; + if ($stdin === false || $stdout === false || $stderr === false) { + throw new \RuntimeException('A temporary file could not be created.'); + } + + fwrite($stdin, (string) $input); + rewind($stdin); + $process = proc_open( + $command, + [0 => $stdin, 1 => $stdout, 2 => $stderr ?? $stdout], + $pipes, + $cwd, + $env === [] ? null : [...getenv(), ...$env], + ); + if ($process === false) { + return [127, 'The command could not be started.']; + } + + $deadline = microtime(as_float: true) + $timeout; + while (($status = proc_get_status($process))['running'] && microtime(as_float: true) < $deadline) { + usleep(20_000); + } + + if ($status['running']) { + self::killTree($status['pid']); + } + + proc_close($process); + fseek($stdout, max(0, (int) fstat($stdout)['size'] - self::MaxOutput)); + return [$status['running'] ? null : $status['exitcode'], (string) stream_get_contents($stdout)]; + } + + + /** + * Ends the process and every process it started at once, as ending the shell alone would leave what it started + * orphaned. + */ + public static function killTree(int $pid): void + { + if (PHP_OS_FAMILY === 'Windows') { + exec("taskkill /F /T /PID $pid 2>NUL"); + return; + } + + $tree = [$pid]; + for ($i = 0; $i < count($tree); $i++) { + exec("pgrep -P {$tree[$i]} 2>/dev/null", $children); + array_push($tree, ...array_map('intval', $children)); + $children = []; + } + + exec('kill -9 ' . implode(' ', $tree) . ' 2>/dev/null'); + } +} diff --git a/src/Engine/Profiler.php b/src/Engine/Profiler.php new file mode 100644 index 0000000000..cd0d8883d4 --- /dev/null +++ b/src/Engine/Profiler.php @@ -0,0 +1,146 @@ +> phase => nanoseconds, calls */ + private array $phases = []; + + /** @var array> rule => nanoseconds, calls, calls that changed the tree */ + private array $rules = []; + + /** @var array> gap rule => nanoseconds, calls of its claims */ + private array $claims = []; + + /** @var list> every file with the phases of its processing */ + private array $files = []; + + /** @var array process id => its peak memory */ + private array $memory = []; + + + public function addPhase(string $phase, int $time, int $calls = 1): void + { + self::add($this->phases, $phase, [$time, $calls]); + } + + + public function addRule(string $rule, int $time, bool $mutated): void + { + self::add($this->rules, $rule, [$time, 1, (int) $mutated]); + } + + + public function addClaim(string $rule, int $time): void + { + self::add($this->claims, $rule, [$time, 1]); + } + + + /** @param array $file path, size, time and the phases of its processing */ + public function addFile(array $file): void + { + $this->files[] = $file; + } + + + /** + * What was measured since the last call, as data for another process, and forgets it. + * @return array + */ + public function takeRecords(): array + { + $records = [ + 'phases' => $this->phases, + 'rules' => $this->rules, + 'claims' => $this->claims, + 'files' => $this->files, + 'memory' => [getmypid() => memory_get_peak_usage()], + ]; + $this->phases = $this->rules = $this->claims = $this->files = []; + return $records; + } + + + /** @param array $records as `takeRecords()` of another process made them */ + public function merge(array $records): void + { + foreach (['phases', 'rules', 'claims'] as $table) { + foreach (is_array($records[$table] ?? null) ? $records[$table] : [] as $name => $values) { + self::add($this->$table, (string) $name, array_values(array_map(intval(...), (array) $values))); + } + } + + foreach (is_array($records['files'] ?? null) ? $records['files'] : [] as $file) { + $this->files[] = (array) $file; + } + + foreach (is_array($records['memory'] ?? null) ? $records['memory'] : [] as $pid => $peak) { + $this->memory[(int) $pid] = max($this->memory[(int) $pid] ?? 0, (int) $peak); + } + } + + + /** + * The profile of the whole run, the most expensive first; every file with its size and time, in the order + * of the paths, for a sample to be drawn from. + * @return array + */ + public function toArray(): array + { + $files = $this->files; + usort($files, fn(array $a, array $b) => $b['time'] <=> $a['time']); + $times = []; + foreach ($files as $file) { + $times[(string) $file['path']] = [(int) $file['size'], (int) $file['time']]; + } + + ksort($times); + $pid = (int) getmypid(); + $this->memory[$pid] = max($this->memory[$pid] ?? 0, memory_get_peak_usage()); + $byTime = static function (array $table, array $names): array { + uasort($table, fn(array $a, array $b) => $b[0] <=> $a[0]); + return array_map(fn(array $values) => array_combine($names, $values), $table); + }; + return [ + 'schema' => self::Schema, + 'files' => count($files), + 'phases' => $byTime($this->phases, ['time', 'calls']), + 'rules' => $byTime($this->rules, ['time', 'calls', 'mutating']), + 'claims' => $byTime($this->claims, ['time', 'calls']), + 'slowestFiles' => array_slice($files, 0, self::SlowestFiles), + 'fileTimes' => $times, + 'peakMemory' => $this->memory, + ]; + } + + + /** + * @param array> $table + * @param list $values + */ + private static function add(array &$table, string $name, array $values): void + { + $table[$name] = isset($table[$name]) + ? array_map(fn(int $a, int $b) => $a + $b, $table[$name], $values) + : $values; + } +} diff --git a/src/Engine/Report.php b/src/Engine/Report.php index d8f9326e08..d3eac951c0 100644 --- a/src/Engine/Report.php +++ b/src/Engine/Report.php @@ -7,6 +7,7 @@ namespace DressCode\Engine; +use DressCode\Config\Layer; use DressCode\Risk; use PhpSyntax\{Node, Token, Trivia}; @@ -58,6 +59,8 @@ public function __construct( public int $lineInTrivia = 0, /** @var ?array{int, int} the offset and the length of what the report is about in the original file */ public ?array $span = null, + /** the section of the upgrading data of a package whose entry the occurrence is of */ + public ?Layer $source = null, ) { } } diff --git a/src/Engine/RulePlan.php b/src/Engine/RulePlan.php index ff4791df72..cc613f7e6d 100644 --- a/src/Engine/RulePlan.php +++ b/src/Engine/RulePlan.php @@ -48,6 +48,7 @@ final class RulePlan public function __construct( /** @var list in configuration order */ public readonly array $rules, + ?Profiler $profiler = null, array $gates = [], ) { $stages = $leaves = []; @@ -79,7 +80,7 @@ public function __construct( $this->stages = $stages; $this->leaves = $leaves; $this->gates = $gates; - $this->claims = new Claims($rules); + $this->claims = new Claims($rules, $profiler); } diff --git a/src/Engine/RunResult.php b/src/Engine/RunResult.php index 36bf349bff..824acfbbec 100644 --- a/src/Engine/RunResult.php +++ b/src/Engine/RunResult.php @@ -7,7 +7,7 @@ namespace DressCode\Engine; -use DressCode\{Risk, Severity, Violation}; +use DressCode\{Risk, Severity}; use function count; @@ -18,7 +18,7 @@ final readonly class RunResult { public function __construct( - /** @var list without their code and output, which a reporter sees in `reportFile()` */ + /** @var list without their texts and violations, which a reporter sees in `reportFile()` */ public array $files, public bool $fix, /** violations the baseline matched */ @@ -34,7 +34,7 @@ public function __construct( /** Violations the files have as they were read. */ public function countViolations(?Severity $severity = null): int { - return self::countBySeverity(array_merge(...array_map(fn(FileSummary $f) => $f->violations, $this->files)), $severity); + return $this->sum(fn(FileSummary $f) => $f->violations->count($severity)); } @@ -44,35 +44,35 @@ public function countViolations(?Severity $severity = null): int */ public function countRemaining(?Severity $severity = null): int { - return self::countBySeverity(array_merge(...array_map(fn(FileSummary $f) => $f->remaining, $this->files)), $severity); + return $this->sum(fn(FileSummary $f) => $f->remaining->count($severity)); } /** Violations left to the user that follow from another one of the same file, whose fix opened, closed or moved their line. */ public function countDerived(?Severity $severity = null): int { - return self::countBySeverity(array_filter($this->listReported(), fn(Violation $v) => $v->derivedFrom !== null), $severity); + return $this->sum(fn(FileSummary $f) => $this->selectReported($f)->countDerived($severity)); } /** Fixes a fix leaves because the run did not allow them; a fix it made is not one of them. */ public function countRefused(): int { - return count($this->listRefused()); + return $this->sum(fn(FileSummary $f) => $f->remaining->countRefused()); } /** Fixes a fix leaves because the run did not allow them, of those the risk would decide. */ public function countRefusedBy(Risk $risk): int { - return count(array_filter($this->listRefused(), fn(Violation $v) => $v->risk === $risk)); + return $this->sum(fn(FileSummary $f) => $f->remaining->countRefused($risk)); } /** Violations the run leaves to the user that no rule has a fix for. */ public function countUnfixable(): int { - return count(array_filter($this->listReported(), fn(Violation $v) => !$v->fixable)); + return $this->sum(fn(FileSummary $f) => $this->selectReported($f)->countUnfixable()); } @@ -101,7 +101,7 @@ public function countFailures(): int */ public function countReported(?Severity $severity = null): int { - return self::countBySeverity($this->listReported(), $severity); + return $this->sum(fn(FileSummary $f) => $this->selectReported($f)->count($severity)); } @@ -120,26 +120,15 @@ public function getExitCode(): int } - /** @return list */ - private function listReported(): array + /** @param \Closure(FileSummary): int $count */ + private function sum(\Closure $count): int { - return array_merge(...array_map(fn(FileSummary $f) => $this->fix ? $f->remaining : $f->violations, $this->files)); + return array_sum(array_map($count, $this->files)); } - /** @return list */ - private function listRefused(): array + private function selectReported(FileSummary $file): ViolationTally { - return array_values(array_filter( - array_merge(...array_map(fn(FileSummary $f) => $f->remaining, $this->files)), - fn(Violation $v) => $v->refused, - )); - } - - - /** @param array $violations */ - private static function countBySeverity(array $violations, ?Severity $severity): int - { - return count(array_filter($violations, fn(Violation $v) => $severity === null || $v->severity === $severity)); + return $this->fix ? $file->remaining : $file->violations; } } diff --git a/src/Engine/Runner.php b/src/Engine/Runner.php index 506223b5b0..33e190bb4a 100644 --- a/src/Engine/Runner.php +++ b/src/Engine/Runner.php @@ -7,8 +7,7 @@ namespace DressCode\Engine; -use DressCode\{ConfigurationException, ConvergenceException, FileResult, Reporter, RuleException}; -use Nette\Utils\{FileSystem, Finder}; +use DressCode\{ConvergenceException, FileResult, Reporter, RuleException}; use function count, is_string, sprintf, strlen; @@ -18,17 +17,20 @@ */ final readonly class Runner { - private string $root; + /** the files of the project, which the run finds and writes */ + public FileFinder $files; private FileProcessors $processors; + /** + * @param list $excludePaths patterns of paths left out + * @param list $fileExtensions + */ public function __construct( FileProcessor|FileProcessors $processors, string $root, - /** @var list patterns of paths left out */ - private array $excludePaths = [], - /** @var list */ - private array $fileExtensions = ['php'], + array $excludePaths = [], + array $fileExtensions = ['php'], /** @var ?\Closure(string $content, string $path): bool files left out by their content */ private ?\Closure $skipWhen = null, /** violations left unreported */ @@ -37,6 +39,8 @@ public function __construct( private ?ResultCache $cache = null, /** the run is narrowed to some of the decisions, so it says nothing about the baseline entries of the others */ private bool $narrowed = false, + /** measures the cycle collection of the files processed in this process */ + private ?Profiler $profiler = null, /** @var ?\Closure(): void fills the cache the workers share, which those started together would all write at once */ public ?\Closure $warmUp = null, /** @var ?\Closure(): ?list the files the results depend on beyond the configuration, once the run knows them */ @@ -49,7 +53,7 @@ public function __construct( /** the configuration lists functions or constants the namespaces declare */ private bool $namespacesListed = false, ) { - $this->root = Helpers::canonicalizePath($root); + $this->files = new FileFinder($root, $excludePaths, $fileExtensions); $this->processors = $processors instanceof FileProcessor ? FileProcessors::of($processors) : $processors; } @@ -57,7 +61,7 @@ public function __construct( /** What a reporter is told of the run before the first file. */ public function createRunInfo(bool $fix, int $fileCount): RunInfo { - return new RunInfo($this->root, $fix, $fileCount, $this->typeAnalysis, $this->typesMissing, $this->namespacesListed); + return new RunInfo($this->files->root, $fix, $fileCount, $this->typeAnalysis, $this->typesMissing, $this->namespacesListed); } @@ -228,7 +232,7 @@ private function processPending( return; } - $collector = new CycleCollector; + $collector = new CycleCollector($this->profiler); try { foreach ($paths as $path) { if ($onProgress !== null) { @@ -252,7 +256,7 @@ private function processPending( */ public function processPath(string $path, bool $fix, ?string $code = null): FileResult { - $path = $this->relativize($path); + $path = $this->files->relativize($path); $code ??= $this->read($path); if ($code === null) { return self::createUnreadable($path); @@ -277,7 +281,7 @@ public function processPath(string $path, bool $fix, ?string $code = null): File ); } - if (!Helpers::writeFile($this->toAbsolute($path), (string) $result->output)) { + if (!Helpers::writeFile($this->files->toAbsolute($path), (string) $result->output)) { return new FileResult($path, $code, output: null, failure: "Cannot write file `$path`."); } @@ -291,7 +295,7 @@ public function processPath(string $path, bool $fix, ?string $code = null): File /** The content of the file, null when it cannot be read. */ private function read(string $path): ?string { - $code = @file_get_contents($this->toAbsolute($path)); // @ the failure is the result of the file + $code = @file_get_contents($this->files->toAbsolute($path)); // @ the failure is the result of the file return $code === false ? null : $code; } @@ -340,7 +344,7 @@ private function remember(ResultCache $cache, FileResult $result): void */ public function processCode(string $path, string $code, array $acceptedRisks = []): FileResult { - $path = $this->relativize($path); + $path = $this->files->relativize($path); $result = $this->processors->get($path)->process($path, $code, $acceptedRisks); $this->baseline?->markMatched($result->path, $result->baselined); return $result; @@ -350,7 +354,7 @@ public function processCode(string $path, string $code, array $acceptedRisks = [ /** Whether the cache knows the text standing for the file to be clean, so that processing it would report nothing. */ public function isKnownClean(string $path, string $code): bool { - return $this->cache?->findClean(ResultCache::hashContent($this->relativize($path), $code)) !== null; + return $this->cache?->findClean(ResultCache::hashContent($this->files->relativize($path), $code)) !== null; } @@ -360,121 +364,6 @@ public function isKnownClean(string $path, string $code): bool */ public function findOverridesFor(string $path): array { - return $this->processors->findOverrides($this->relativize($path)); - } - - - /** - * Files under the paths with one of the extensions, minus the excluded ones; an explicitly given file - * is taken as is, unless `$skipExcluded` lets the excluded paths leave it out like a found one, and so the - * configured paths where they are given, which is what a hook or an editor naming every file it touches wants; - * a file outside the root has no path the patterns could match. Sorted, relative to the root. - * @param list $paths - * @param list $configured the configured paths, outside which `$skipExcluded` leaves a named file out - * @return list - * @throws ConfigurationException for a path that does not exist - */ - public function findFiles(array $paths, bool $skipExcluded = false, array $configured = []): array - { - $files = []; - foreach ($paths as $path) { - $path = $this->relativize($path); - $absolute = $this->toAbsolute($path); - if (is_file($absolute)) { - if (!$skipExcluded || !$this->excludes($path, $configured)) { - $files[$path] = true; - } - } elseif (is_dir($absolute)) { - $finder = Finder::findFiles(array_map(fn($ext) => "*.$ext", $this->fileExtensions)) - ->from($absolute) - ->descentFilter( - fn(\SplFileInfo $dir) => !Helpers::matchesAny($this->excludePaths, $this->relativize($dir->getPathname())), - ); - foreach ($finder as $file) { - $relative = $this->relativize($file->getPathname()); - if (!Helpers::matchesAny($this->excludePaths, $relative)) { - $files[$relative] = true; - } - } - } else { - throw new ConfigurationException("Path `$path` does not exist."); - } - } - - $files = array_keys($files); - sort($files, SORT_STRING); - return $files; - } - - - /** - * Whether the excluded paths leave out a file named by its path, or the configured paths do not hold it; a file - * outside the root has no path the patterns could match, so only configured paths leave it out. - * @param list $configured - */ - public function excludes(string $path, array $configured = []): bool - { - $path = $this->relativize($path); - $configured = array_map($this->relativize(...), $configured); - if (FileSystem::isAbsolute($path)) { - return $configured !== []; - } - - return Helpers::matchesAny($this->excludePaths, $path) || ($configured !== [] && !array_any( - $configured, - fn(string $inner) => $inner === '' || $path === $inner || str_starts_with($path, "$inner/"), - )); - } - - - /** - * The paths named within the configured ones: a directory holding some of those stands for them, anything else - * for itself, since whoever names a path outside them means it. - * @param list $paths - * @param list $configured - * @return list - */ - public function narrowPaths(array $paths, array $configured): array - { - $configured = array_map($this->relativize(...), $configured); - $narrowed = []; - foreach ($paths as $path) { - $relative = $this->relativize($path); - $held = in_array($relative, $configured, true) - ? [] - : array_filter($configured, fn(string $inner) => $relative === '' || str_starts_with("$inner/", "$relative/")); - array_push($narrowed, ...($held ?: [$path])); - } - - return array_values(array_unique($narrowed)); - } - - - /** - * A path outside the root stays absolute, a relative one is under the root. - */ - public function toAbsolute(string $path): string - { - return match (true) { - $path === '' => $this->root, - FileSystem::isAbsolute($path) => $path, - default => $this->root . '/' . $path, - }; - } - - - /** - * Path with slashes, relative to the root when it lies under it. - */ - public function relativize(string $path): string - { - $path = Helpers::canonicalizePath($path); - if ($path === $this->root) { - return ''; - } elseif (str_starts_with($path, $this->root . '/')) { - $path = substr($path, strlen($this->root) + 1); - } - - return implode('/', array_filter(explode('/', $path), fn(string $segment) => $segment !== '.')); // "./a" and "." would match the ".*" exclusion + return $this->processors->findOverrides($this->files->relativize($path)); } } diff --git a/src/Engine/Suppression.php b/src/Engine/Suppression.php index 65684aad3e..cd0dee7dba 100644 --- a/src/Engine/Suppression.php +++ b/src/Engine/Suppression.php @@ -10,11 +10,13 @@ use PhpSyntax\{Node, Token, Trivia}; use PhpSyntax\Nodes\{FileNode, PlainNodeList, SeparatedNodeList}; use function count; +use const PREG_OFFSET_CAPTURE, PREG_UNMATCHED_AS_NULL; /** * Which rules are silenced on which original lines, read once from the comments of the file before any mutation: - * the dresscode:ignore, disable, enable and ignoreFile comments. What follows ` -- ` says why, for the reader alone. + * the dresscode:ignore, disable, enable and ignoreFile comments, and the forms of phpcs, whose names Interop + * translates, or which name ours, as the migration writes them. What follows ` -- ` says why, for the reader alone. * @internal */ final class Suppression @@ -36,33 +38,40 @@ final class Suppression public static function fromFile(FileNode $file, \Closure $expandName, ?string $code = null, array $comments = []): self { $suppression = new self; - if ($code !== null && $comments === [] && !str_contains($code, 'dresscode:')) { // nothing to read + if ($code !== null && $comments === [] && !str_contains($code, 'dresscode:') && !str_contains($code, 'phpcs')) { // nothing to read return $suppression; } $disabled = []; $lastLine = max($file->endOfFile->line, 1); foreach ($file->getTokens() as $token) { - foreach ([$token->leadingTrivia, $token->trailingTrivia] as $trivias) { + foreach ([$token->leadingTrivia, $token->trailingTrivia] as $side => $trivias) { foreach ($trivias as $index => $trivia) { if (!$trivia->isComment()) { continue; - } elseif (preg_match( - '~dresscode:(ignoreFile|ignore|disable|enable)(?:\s+([\w/.\\\\][\w/.\\\\,\s-]*?))?(?:\s+--(?:\s.*?)?)?(?=\s*(?:\*/|$))~m', - $trivia->text, - $m, - )) { - $names = $suppression->expandNames($m[2] ?? '', $expandName, $m[0]); + } elseif ($trivia->id === Trivia::DocComment && str_contains($trivia->text, '@phpcsSuppress')) { + $suppression->collectPhpcsSuppress( + $trivia, + $side === 0 ? $token->getPrevious() : $token, + $side === 0 ? $token : $token->getNext(), + $expandName, + ); + } + + $directive = self::findDirective($trivia->text); + if ($directive !== null) { + $names = $suppression->expandNames($directive['names'], $expandName, $directive['text']); + $kind = $directive['kind']; } elseif ($names = self::matchComment($trivia->text, $comments)) { - $m = [1 => 'ignore']; + $kind = 'ignore'; } else { continue; } $line = $trivia->line; - if ($m[1] === 'ignoreFile') { + if ($kind === 'ignoreFile') { $suppression->add([self::All], 1, PHP_INT_MAX); - } elseif ($m[1] === 'ignore') { + } elseif ($kind === 'ignore') { $ownLine = self::isAlone($trivias, $index, $token); $next = $line + preg_match_all('~\r\n|\r|\n~', $trivia->text) + 1; $target = null; @@ -76,7 +85,7 @@ public static function fromFile(FileNode $file, \Closure $expandName, ?string $c $target ? self::getEndLine($target) : ($ownLine ? $next : $line), ); - } elseif ($m[1] === 'disable') { + } elseif ($kind === 'disable') { foreach ($names as $name) { $disabled[$name] ??= $line; } @@ -100,6 +109,31 @@ public static function fromFile(FileNode $file, \Closure $expandName, ?string $c } + /** + * The directive the comment holds, `dresscode:ignore upgrading -- why` or one of phpcs: its kind, the names it lists, + * the reason after ` -- `, its text and where it starts in the comment; null for none. A directive runs to the end of + * its comment or its line. + * @return ?array{kind: string, names: string, reason: string, text: string, offset: int} + */ + public static function findDirective(string $comment): ?array + { + return preg_match( + '~(?:dresscode|phpcs):(ignoreFile|ignore|disable|enable)(?:\s+([\w/.\\\\][\w/.\\\\,\s-]*?))?(?:\s+--(?:\s(.*?))?)?(?=\s*(?:\*/|$))~m', + $comment, + $m, + PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL, + ) + ? [ + 'kind' => (string) $m[1][0], + 'names' => (string) ($m[2][0] ?? ''), + 'reason' => trim((string) ($m[3][0] ?? '')), + 'text' => (string) $m[0][0], + 'offset' => $m[0][1], + ] + : null; + } + + /** Whether the decision is silenced on the line: by its path, by a section or a structure above it, or by all. */ public function isSilenced(string $decision, int $line): bool { @@ -169,6 +203,33 @@ public function getUnknownNames(): array } + /** + * The tags of a doc comment silence the node it documents: the one starting at the token after it, or one ending + * at the token before it, as a parameter does. + * @param \Closure(string): list $expandName + */ + private function collectPhpcsSuppress(Trivia $doc, ?Token $before, ?Token $after, \Closure $expandName): void + { + $node = $after ? self::findOutermostNode($after) : null; + if ($node?->getDocComment() !== $doc) { + for ( + $node = $before?->parent; + $node && $node->getLastToken() === $before && $node->getDocComment() !== $doc; + $node = $node->parent + ); + } + + $from = $node?->getFirstToken()?->line; + if ( + $from !== null + && $node->getDocComment() === $doc + && preg_match_all('~@phpcsSuppress[ \t]+([\w/][\w/.-]*(?:[ \t]*,[ \t]*[\w/][\w/.-]*)*)~', $doc->text, $m) + ) { + $this->add($this->expandNames(implode(',', $m[1]), $expandName), $from, self::getEndLine($node)); + } + } + + /** * @param array> $comments * @return list diff --git a/src/Engine/SuppressionMigration.php b/src/Engine/SuppressionMigration.php new file mode 100644 index 0000000000..fefe7800b1 --- /dev/null +++ b/src/Engine/SuppressionMigration.php @@ -0,0 +1,142 @@ +> name => lines, in the file being migrated */ + private array $unknownNames = []; + + /** @var list in the file being migrated */ + private array $widened = []; + + + public function __construct( + /** @var \Closure(string): list */ + private readonly \Closure $expandName, + ) { + } + + + /** + * The suppression comments of PHP_CodeSniffer the files hold that `migrate()` would rewrite, writing nothing; a file + * that does not parse is migrated neither. + * @param list $files relative to the root + */ + public function countInFiles(string $root, array $files): int + { + $count = 0; + $parser = new Parser; + foreach ($files as $file) { + $code = (string) @file_get_contents("$root/$file"); // @ file may have been deleted meanwhile + try { + $count += str_contains($code, 'phpcs') ? $this->migrate($parser->parse($code))->count : 0; + } catch (ParseException) { + } + } + + return $count; + } + + + /** Rewrites the comments of the file and says what it did to them. */ + public function migrate(FileNode $file): SuppressionRewrite + { + [$this->count, $this->unknownNames, $this->widened] = [0, [], []]; + foreach ($file->getTokens() as $token) { + // whether a comment has a line of its own is read before the first of them is rewritten + $comments = []; + foreach ([$token->leadingTrivia, $token->trailingTrivia] as $trivias) { + foreach ($trivias as $index => $trivia) { + if ($trivia->isComment()) { + $comments[] = [$trivia, Suppression::isAlone($trivias, $index, $token)]; + } + } + } + + foreach ($comments as [$trivia, $ownLine]) { + $text = $this->rewrite($trivia, $ownLine); + if ($text !== $trivia->text) { + $token->replaceTrivia($trivia, $trivia->withText($text)); + } + } + } + + return new SuppressionRewrite($this->count, $this->unknownNames, $this->widened); + } + + + private function rewrite(Trivia $trivia, bool $ownLine): string + { + // the rewrites keep the line endings, so the line is counted in the text as it stands + $lineOf = fn(string $text, int $offset) => $trivia->line + substr_count($text, "\n", 0, $offset); + $text = (string) preg_replace_callback( + self::Directive, + function (array $m) use ($ownLine, $lineOf, $trivia): string { + $this->count++; + $kind = $m[1][0]; + $line = $lineOf($trivia->text, $m[0][1]); + if ($kind === 'ignore' && $ownLine) { + $this->widened[] = $line; + } + + $names = preg_split('~[,\s]+~', trim($m[2][0]), -1, PREG_SPLIT_NO_EMPTY) ?: []; + return "dresscode:$kind" + . ($names ? ' ' . implode(', ', array_map(fn(string $name) => $this->getCanonicalName($name, $line), $names)) : ''); + }, + $trivia->text, + flags: PREG_OFFSET_CAPTURE, + ); + return (string) preg_replace_callback( + self::Tag, + function (array $m) use ($lineOf, $text): string { + $line = $lineOf($text, $m[0][1]); + $names = (array) preg_split('~[,\s]+~', $m[1][0], -1, PREG_SPLIT_NO_EMPTY); + $canonical = implode(', ', array_map(fn(string $name) => $this->getCanonicalName($name, $line), $names)); + if ($canonical !== implode(', ', $names)) { + $this->count++; + } + + return "@phpcsSuppress $canonical"; + }, + $text, + flags: PREG_OFFSET_CAPTURE, + ); + } + + + private function getCanonicalName(string $name, int $line): string + { + $resolved = ($this->expandName)($name); + if (!$resolved) { + $this->unknownNames[$name][] = $line; + return $name; + } + + return implode(', ', array_map(PluginRegistry::abbreviate(...), $resolved)); + } +} diff --git a/src/Engine/SuppressionRewrite.php b/src/Engine/SuppressionRewrite.php new file mode 100644 index 0000000000..30422110ed --- /dev/null +++ b/src/Engine/SuppressionRewrite.php @@ -0,0 +1,26 @@ +> a name nothing covers, kept as written => the lines it stands on */ + public array $unknownNames = [], + /** @var list the lines of an ignore on a line of its own, whose scope grows from the next line to the whole next statement */ + public array $widened = [], + ) { + } +} diff --git a/src/Engine/ViolationTally.php b/src/Engine/ViolationTally.php new file mode 100644 index 0000000000..6c0be573e1 --- /dev/null +++ b/src/Engine/ViolationTally.php @@ -0,0 +1,78 @@ + the severity => how many */ + private array $all, + /** @var array the severity => how many follow from another violation of the file */ + private array $derived, + /** @var array the risk => how many fixes the run did not allow */ + private array $refused, + private int $unfixable, + ) { + } + + + /** @param list $violations */ + public static function of(array $violations): self + { + $all = $derived = $refused = []; + $unfixable = 0; + foreach ($violations as $violation) { + $severity = $violation->severity->value; + $all[$severity] = ($all[$severity] ?? 0) + 1; + if ($violation->derivedFrom !== null) { + $derived[$severity] = ($derived[$severity] ?? 0) + 1; + } + + if ($violation->refused) { + $risk = $violation->risk->value ?? ''; + $refused[$risk] = ($refused[$risk] ?? 0) + 1; + } + + $unfixable += $violation->fixable ? 0 : 1; + } + + return new self($all, $derived, $refused, $unfixable); + } + + + public function count(?Severity $severity = null): int + { + return $severity === null ? array_sum($this->all) : $this->all[$severity->value] ?? 0; + } + + + public function countDerived(?Severity $severity = null): int + { + return $severity === null ? array_sum($this->derived) : $this->derived[$severity->value] ?? 0; + } + + + public function countRefused(?Risk $risk = null): int + { + return $risk === null ? array_sum($this->refused) : $this->refused[$risk->value] ?? 0; + } + + + public function countUnfixable(): int + { + return $this->unfixable; + } +} diff --git a/src/Engine/Worker.php b/src/Engine/Worker.php index a127f5ffce..ba76b4536e 100644 --- a/src/Engine/Worker.php +++ b/src/Engine/Worker.php @@ -19,7 +19,7 @@ final class Worker { /** @return int exit code */ - public static function run(string $address, Runner $runner, bool $fix): int + public static function run(string $address, Runner $runner, bool $fix, ?Profiler $profiler = null): int { $context = stream_context_create(['socket' => ['tcp_nodelay' => true]]); // Nagle would delay every small message by an ACK $socket = @stream_socket_client("tcp://$address", $errno, $error, timeout: 10, context: $context); // @ reported below @@ -28,10 +28,15 @@ public static function run(string $address, Runner $runner, bool $fix): int return 2; } - $collector = new CycleCollector; + $collector = new CycleCollector($profiler); $sent = false; while (($line = fgets($socket)) !== false) { $job = json_decode($line, associative: true); + if (is_array($job) && ($job['finish'] ?? null) === true) { // nothing is left; the parent waits for what was measured since the last file + fwrite($socket, json_encode(['profile' => $profiler?->takeRecords() ?? []], JSON_THROW_ON_ERROR) . "\n"); + continue; + } + $path = is_array($job) && is_string($job['path'] ?? null) ? base64_decode($job['path'], strict: true) : false; if ($path === false) { fwrite(STDERR, "Unexpected message from the parent: $line"); @@ -39,7 +44,7 @@ public static function run(string $address, Runner $runner, bool $fix): int } $result = $runner->processPath($path, $fix); - $message = WorkerCodec::encode($result); + $message = WorkerCodec::encode($result, $profiler?->takeRecords()); // the files the results depend on and what the analyses tell, with the first result after they are known if (!$sent && ($dependencies = ($runner->findDependencies)?->__invoke()) !== null) { $message['dependencies'] = $dependencies; diff --git a/src/Engine/WorkerCodec.php b/src/Engine/WorkerCodec.php index 10420b22fb..5c8fe0f449 100644 --- a/src/Engine/WorkerCodec.php +++ b/src/Engine/WorkerCodec.php @@ -7,19 +7,24 @@ namespace DressCode\Engine; +use DressCode\Config\{Layer, LayerKind}; use DressCode\{FileResult, Risk, Rule, Severity, Violation}; use function is_array, is_string; /** - * The result of a file as a worker sends it to the parent: the output only where it differs from the code, which - * the parent has, the output and the path base64-encoded since neither need be UTF-8. + * The result of a file as a worker sends it to the parent, with what it measured for `--profile`: the output only + * where it differs from the code, which the parent has, the output and the path base64-encoded since neither need + * be UTF-8. * @internal */ final class WorkerCodec { - /** @return array */ - public static function encode(FileResult $result): array + /** + * @param ?array $profile see `Profiler::takeRecords()` + * @return array + */ + public static function encode(FileResult $result, ?array $profile): array { return [ 'path' => base64_encode($result->path), @@ -37,6 +42,7 @@ public static function encode(FileResult $result): array 'cached' => $result->cached, 'removedImports' => $result->removedImports, 'mutatedRules' => $result->mutatedRules, + 'profile' => $profile, ]; } @@ -44,11 +50,12 @@ public static function encode(FileResult $result): array /** * @param array $data as `encode()` made it * @param string $code the content the parent handed the worker + * @return array{FileResult, ?array} the result and what the worker measured with it */ - public static function decode(array $data, string $code): FileResult + public static function decode(array $data, string $code): array { $output = $data['output']; - return new FileResult( + $result = new FileResult( path: (string) base64_decode((string) $data['path'], strict: true), code: $code, output: $output === true ? $code : ($output === null ? null : (string) base64_decode((string) $output, strict: true)), @@ -75,11 +82,12 @@ public static function decode(array $data, string $code): FileResult ], is_array($data['removedImports']) ? $data['removedImports'] : [])), mutatedRules: is_array($data['mutatedRules']) ? array_values(array_filter($data['mutatedRules'], self::isRule(...))) : [], ); + return [$result, is_array($data['profile']) ? $data['profile'] : null]; } /** @return array */ - private static function encodeViolation(Violation $violation): array + public static function encodeViolation(Violation $violation): array { return [ 'decision' => $violation->decision, @@ -96,12 +104,23 @@ private static function encodeViolation(Violation $violation): array 'offset' => $violation->offset, 'length' => $violation->length, 'originalLines' => $violation->originalLines, + 'source' => $violation->source === null ? null : [ + $violation->source->name, + $violation->source->package, + $violation->source->decision, + $violation->source->library, + $violation->source->since, + $violation->source->from, + ], ]; } - /** @return list */ - private static function decodeViolations(mixed $data): array + /** + * @param mixed $data a list of what `encodeViolation()` made + * @return list + */ + public static function decodeViolations(mixed $data): array { return array_values(array_map(fn(array $item) => new Violation( (string) $item['decision'], @@ -118,10 +137,19 @@ private static function decodeViolations(mixed $data): array $item['offset'] === null ? null : (int) $item['offset'], $item['length'] === null ? null : (int) $item['length'], is_array($item['originalLines']) ? [(int) $item['originalLines'][0], (int) $item['originalLines'][1]] : null, + is_array($item['source']) ? self::decodeSource($item['source']) : null, ), is_array($data) ? $data : [])); } + /** @param array $data */ + private static function decodeSource(array $data): Layer + { + [$name, $package, $decision, $library, $since, $from] = array_map(fn($value) => $value === null ? null : (string) $value, $data); + return new Layer(LayerKind::Package, $name, $package, $decision, $library, $since, $from); + } + + /** @phpstan-assert-if-true class-string $class */ private static function isRule(mixed $class): bool { diff --git a/src/Engine/WorkerPool.php b/src/Engine/WorkerPool.php index e29db87c42..bae19721be 100644 --- a/src/Engine/WorkerPool.php +++ b/src/Engine/WorkerPool.php @@ -8,7 +8,7 @@ namespace DressCode\Engine; use DressCode\FileResult; -use function array_key_exists, count, function_exists, in_array, is_array, strlen; +use function array_key_exists, count, dirname, function_exists, in_array, is_array, strlen; use const PHP_OS_FAMILY, PHP_SAPI; @@ -20,6 +20,9 @@ */ final class WorkerPool { + /** microseconds the wait for the workers lasts at most while the progress is watched, so that a spinner turns */ + private const ProgressInterval = 100_000; + /** * The listening socket: from ext/sockets when available, because the stream layer on Windows waits 500 ms * for a socket to become writable before closing it, which a listening one never does. @@ -57,6 +60,9 @@ final class WorkerPool /** @var list */ private array $queue = []; + /** @var array workers asked for what they measured last, by socket id */ + private array $finishing = []; + /** @var array the files the results depend on, as the workers sent them */ private array $dependencies = []; @@ -74,12 +80,24 @@ public function __construct( private readonly int $taskTimeout = 300, /** seconds a worker may take to connect, the first one filling the cache the others share among them */ private readonly int $connectTimeout = 300, + /** with it, every worker hands over what it measured last once nothing is left, one that got no file among them */ + private readonly ?Profiler $profiler = null, /** the first worker fills a cache the others share before it connects, and they start after that */ private readonly bool $warmFirst = false, ) { } + /** + * The workers a run of the files takes by default: a worker costs about the processing of a few files to start, so one + * for every four files at most, and no more than the processors. + */ + public static function countDefaultJobs(int $files): int + { + return max(1, min(self::detectCpuCount(), intdiv($files, 4))); + } + + /** Processors the machine has, 1 when unknown. */ public static function detectCpuCount(): int { @@ -96,6 +114,16 @@ public static function detectCpuCount(): int } + /** + * The program of this very package, which a process of DressCode is started as: never the one the process itself was + * started as, which for a test or an application embedding DressCode is another program. + */ + public static function locateProgram(): string + { + return \Phar::running(returnPhar: false) ?: dirname(__DIR__, 2) . '/bin/dresscode'; + } + + /** * The PHP a worker is started with: the same binary with the same ini file, the binary alone loading the default * one, and the settings the process has beyond it; null where no CLI binary is found to start one. @@ -184,8 +212,8 @@ public function process(array $paths, \Closure $read, ?\Closure $onProgress = nu $this->workers[] = $this->spawn($address); } - while ($done < count($paths)) { - foreach ($this->await() as $stream) { + while ($done < count($paths) || $this->isFinishing()) { + foreach ($this->await(watched: $onProgress !== null) as $stream) { foreach ($this->receive((int) $stream) as $path => $result) { $done++; yield $path => $result; @@ -261,14 +289,15 @@ private function listen(): string /** * Waits for messages from the workers, accepting the ones that connect meanwhile; the listening socket * from ext/sockets is closed as soon as the last worker is in. + * @param bool $watched the progress is drawn, so the wait is short * @return list connections with a message waiting */ - private function await(): array + private function await(bool $watched): array { $read = array_values($this->sockets); $write = $except = []; - $seconds = 1; - $microseconds = 0; + $seconds = $watched ? 0 : 1; + $microseconds = $watched ? self::ProgressInterval : 0; $server = $this->server; if ($server instanceof \Socket) { $this->acceptPending($server, blocking: $read === []); @@ -399,6 +428,12 @@ private function receive(int $id): array while (($end = strpos($buffer, "\n")) !== false) { $data = json_decode(substr($buffer, 0, $end), associative: true); $buffer = substr($buffer, $end + 1); + if (isset($this->finishing[$id]) && is_array($data['profile'] ?? null)) { + $this->profiler?->merge($data['profile']); + $this->disconnect($id); + break; + } + [$path, , $code] = $this->inProgress[$id] ?? [null, null, '']; if (!is_array($data) || $path === null || ($data['path'] ?? null) !== base64_encode($path)) { throw new \RuntimeException('A worker sent an unexpected message.' . $this->collectErrors()); @@ -413,7 +448,11 @@ private function receive(int $id): array $this->warnings[(string) $warning] = true; } - $results[$path] = WorkerCodec::decode($data, $code); + [$results[$path], $profile] = WorkerCodec::decode($data, $code); + if ($profile !== null) { + $this->profiler?->merge($profile); + } + $this->assign($id); } @@ -427,11 +466,15 @@ private function receive(int $id): array } - /** Hands the next path to the worker, or closes its connection when there is none. */ + /** Hands the next path to the worker; when there is none, asks it for what it measured last, or closes its connection. */ private function assign(int $id): void { $path = array_shift($this->queue); - if ($path === null) { + if ($path === null && $this->profiler !== null) { + $this->finishing[$id] = true; + fwrite($this->sockets[$id], json_encode(['finish' => true], JSON_THROW_ON_ERROR) . "\n"); + return; + } elseif ($path === null) { $this->disconnect($id); return; } @@ -457,7 +500,25 @@ private function getInProgress(): array private function disconnect(int $id): void { fclose($this->sockets[$id]); - unset($this->sockets[$id], $this->inProgress[$id], $this->buffers[$id]); + unset($this->sockets[$id], $this->inProgress[$id], $this->buffers[$id], $this->finishing[$id]); + } + + + /** + * Whether the run still waits for what the workers measured last: from one asked for it, and from one still + * starting, which gets nothing to process but has its peak memory. + */ + private function isFinishing(): bool + { + if ($this->profiler === null) { + return false; + } elseif ($this->finishing !== []) { + return true; + } + + // which process a connection belongs to is unknown, so a worker done with its work is waited for too, until it exits + return $this->accepted < count($this->workers) + && array_any($this->workers, fn(array $worker) => proc_get_status($worker[0])['running']); } diff --git a/src/Interop/ForeignRules.php b/src/Interop/ForeignRules.php new file mode 100644 index 0000000000..1a9ebdd29d --- /dev/null +++ b/src/Interop/ForeignRules.php @@ -0,0 +1,31 @@ +> foreign rule => on, off or its options */ + public array $rules, + /** @var list the presets of DressCode the preset of the configuration is */ + public array $presets = [], + /** @var list what of the configuration is not carried over */ + public array $warnings = [], + /** the indent the fixers of whitespace write */ + public string $indent = ' ', + /** the line ending the fixers of whitespace write */ + public string $lineEnding = "\n", + ) { + } +} diff --git a/src/Interop/FormatterCandidate.php b/src/Interop/FormatterCandidate.php new file mode 100644 index 0000000000..8bbb9cd612 --- /dev/null +++ b/src/Interop/FormatterCandidate.php @@ -0,0 +1,42 @@ +file` of {$this->formatter->value}" . ($this->runBy === null ? '' : ", run by `$this->runBy`"); + } + + + /** @throws ConfigurationException */ + public function read(string $root): FormatterConfig + { + return FormatterConfig::read($root, $this->file, $this->formatter, $this->runBy); + } +} diff --git a/src/Interop/FormatterConfig.php b/src/Interop/FormatterConfig.php new file mode 100644 index 0000000000..24c86cdb04 --- /dev/null +++ b/src/Interop/FormatterConfig.php @@ -0,0 +1,314 @@ + the directories and files it checks, relative to the root; empty where it names none */ + public array $paths, + /** @var list regular expressions of the paths, relative to the root, it leaves out */ + public array $excludePatterns, + /** @var array> foreign rule => regular expressions of the paths it leaves out */ + public array $ruleExcludePatterns, + /** absolute */ + private string $path, + /** @var list what of the configuration could be neither read nor left aside as running it only, `in($dirs)` */ + public array $unread = [], + /** @var list regular expressions of the names of the files it leaves out */ + public array $nameExcludePatterns = [], + /** @var ?list the extensions of the files it checks, without a dot; null where it names none */ + public ?array $extensions = null, + ) { + } + + + /** The configuration in the file given, relative to the root or absolute, the formatter told by its extension. */ + public static function fromFile(string $root, string $file): self + { + $file = Helpers::canonicalizePath($file); + $root = Helpers::canonicalizePath($root); + $file = str_starts_with($file, "$root/") ? substr($file, strlen($root) + 1) : $file; + return self::read($root, $file, Formatter::fromFileName($file)); + } + + + /** The configuration of the formatter in the file given, relative to the root or absolute, and how the project runs it. */ + public static function read(string $root, string $file, Formatter $formatter, ?string $runBy = null): self + { + $path = FileSystem::isAbsolute($file) ? $file : "$root/$file"; + return match ($formatter) { + Formatter::PhpCodeSniffer => self::readPhpCodeSniffer($root, $path, $file, $runBy), + Formatter::PhpCsFixer => self::readPhpCsFixer($root, $path, $file, $runBy), + Formatter::Pint => self::readPint($path, $file, $runBy), + }; + } + + + /** Laravel Pint: the standard of its preset, `laravel` where it names none, and the directories it leaves out. */ + private static function readPint(string $path, string $file, ?string $runBy): self + { + // a file that is no JSON names no preset, which is not the default preset of a file naming none + $config = ComposerJson::read($path); + $excludes = array_map( + fn($dir) => '~^' . preg_quote(trim((string) $dir, '/'), '~') . '/~', + array_values((array) ($config['exclude'] ?? [])), + ); + return new self( + Formatter::Pint, + $file, + $runBy, + $config === null ? null : Pint::findStandard($config['preset'] ?? null), + [], + $excludes, + [], + $path, + $config === null ? [basename($file) . ', which is no JSON'] : [], + ); + } + + + /** + * A ruleset is data: the standard it references, the files it checks, the extensions and its own exclude patterns, + * those of its rules, and the rest of it, which is how PHP_CodeSniffer runs, or else unread. A pattern is a regular + * expression in which `*` stands for anything; an absolute one is matched against the path with a slash in front, a + * relative one against the path. + */ + private static function readPhpCodeSniffer(string $root, string $path, string $file, ?string $runBy): self + { + $xml = @simplexml_load_file($path); // @ a ruleset that is no XML gives nothing + if ($xml === false) { + return new self(Formatter::PhpCodeSniffer, $file, $runBy, null, [], [], [], $path, [basename($file) . ', which is no XML']); + } + + $toRegex = fn(string $pattern, bool $relative = false) => '~' . ($relative ? '^' : '') + . str_replace(['~', '*'], ['\~', '.*'], trim($pattern)) . '~i'; + $standard = $excludes = $ruleExcludes = $unread = []; + $extensions = null; + foreach ($xml->{'exclude-pattern'} ?? [] as $pattern) { + $excludes[] = $toRegex((string) $pattern, (string) $pattern['type'] === 'relative'); + } + + foreach ($xml->arg ?? [] as $arg) { + [$name, $value] = [(string) $arg['name'], (string) $arg['value']]; + if ($name === 'extensions') { // `inc/php` names the tokenizer beside the extension + $extensions = array_values(array_unique(array_map(fn(string $item) => explode('/', trim($item))[0], explode(',', $value)))); + } elseif ($name === 'ignore') { + array_push($excludes, ...array_map($toRegex, explode(',', $value))); + } elseif (!self::isRunningArgument($name, $value)) { + $unread[] = $name === '' ? "" : ""; + } + } + + foreach ($xml->config ?? [] as $config) { + if ((string) $config['name'] !== 'installed_paths') { + $unread[] = ""; + } + } + + foreach ($xml->children() as $element) { + if (!in_array( + $element->getName(), + ['file', 'exclude-pattern', 'rule', 'arg', 'config', 'ini', 'autoload', 'description'], + true, + )) { + $unread[] = "<{$element->getName()}>"; + } + } + + foreach ($xml->rule ?? [] as $rule) { + $ref = (string) $rule['ref']; + $standard[] = PhpCodeSniffer::getSets()[$ref] ?? null; + foreach ($rule->{'exclude-pattern'} ?? [] as $pattern) { + $ruleExcludes[$ref][] = $toRegex((string) $pattern, (string) $pattern['type'] === 'relative'); + } + } + + $standard = array_values(array_filter($standard))[0] ?? null; + return new self( + Formatter::PhpCodeSniffer, + $file, + $runBy, + $standard === null ? null : PluginRegistry::abbreviate($standard), + self::resolvePaths($root, $path, array_map(strval(...), iterator_to_array($xml->file, preserve_keys: false))), + $excludes, + $ruleExcludes, + $path, + $unread, + extensions: $extensions, + ); + } + + + /** Whether the argument of a ruleset says only how PHP_CodeSniffer runs and reports, nothing of what it checks. */ + private static function isRunningArgument(string $name, string $value): bool + { + return $name === '' + ? (bool) preg_match('~^[psvq]+$~D', $value) // progress, sniff codes, verbosity, quiet + : in_array( + $name, + ['basepath', 'cache', 'no-cache', 'colors', 'no-colors', 'encoding', 'parallel', 'report', 'report-width'], + true, + ) || str_starts_with($name, 'report-'); + } + + + /** + * A configuration of PHP CS Fixer is PHP, read from its syntax tree without running it (`PhpCsFixerSource`): the sets + * its rules turn on, the directories its finder takes, `->in(__DIR__ . '/lib')`, and what it leaves out of them, a + * directory `->exclude('Parser')`, a path `->notPath('Fixtures')` and a name `->notName('*.phpt')`. + */ + private static function readPhpCsFixer(string $root, string $path, string $file, ?string $runBy): self + { + $source = PhpCsFixerSource::fromCode((string) @file_get_contents($path), $path); // @ file may have been deleted meanwhile + $standard = null; + // @PhpCsFixer is @Symfony with more rules, which the standard is the nearest to + foreach ([...PhpCsFixer::getSets(), '@PhpCsFixer' => 'dresscode/symfony'] as $set => $preset) { + if (in_array($set, $source->sets, true)) { + $standard ??= PluginRegistry::abbreviate($preset); + } + } + + // a finder of appended files alone takes those, one of neither the directory of the configuration + $in = $source->in ?: ($source->append === [] ? [dirname($path)] : []); + $dirs = self::resolvePaths($root, $path, $in); + $excludes = []; + foreach ($dirs === ['.'] ? [''] : $dirs as $dir) { + foreach ($source->exclude as $name) { + $excludes[] = '~^' . preg_quote(ltrim($dir . '/' . trim($name, '/'), '/'), '~') . '/~'; + } + } + + foreach ($source->notPath as $pattern) { + $excludes[] = @preg_match($pattern, '') === false ? '~' . preg_quote($pattern, '~') . '~' : $pattern; // @ a text is no pattern + } + + $names = array_map( + fn(string $name) => @preg_match($name, '') === false // @ a glob is no pattern + ? '~^' . strtr(preg_quote($name, '~'), ['\\*' => '.*', '\\?' => '.']) . '$~' + : $name, + $source->notName, + ); + + return new self( + Formatter::PhpCsFixer, + $file, + $runBy, + $standard, + $source->append === [] ? $dirs : self::resolvePaths($root, $path, [...$in, ...$source->append]), + $excludes, + [], + $path, + $source->unread, + $names, + ); + } + + + /** + * The paths a configuration names relative to its own directory or absolute, as paths relative to the root; one + * outside the root is dropped, one missing refused as the formatter refuses it, and the root among them, which takes + * all, is `.` alone. + * @param list $paths + * @return list + * @throws ConfigurationException for a path that does not exist + */ + private static function resolvePaths(string $root, string $file, array $paths): array + { + $root = Helpers::canonicalizePath(FileSystem::normalizePath($root)); + $resolved = []; + foreach ($paths as $path) { + $full = Helpers::canonicalizePath(FileSystem::resolvePath(dirname($file), $path)); + if (!file_exists($full)) { + throw new ConfigurationException("`$file` names `$path`, which does not exist."); + } elseif ($full === $root) { + return ['.']; + } elseif (str_starts_with($full, "$root/")) { + $resolved[] = substr($full, strlen($root) + 1); + } + } + + sort($resolved, SORT_STRING); + return array_values(array_unique($resolved)); + } + + + /** The configuration as a reader is told of it, `pint.json` of Laravel Pint, run by `composer fix`. */ + public function format(): string + { + return "`$this->file` of {$this->formatter->value}" . ($this->runBy === null ? '' : ", run by `$this->runBy`"); + } + + + /** Whether the formatter leaves the path out, by its path or its name. */ + public function excludes(string $path): bool + { + return self::matchesPath($this->excludePatterns, $path) + || array_any($this->nameExcludePatterns, fn(string $regex) => preg_match($regex, basename($path)) === 1); + } + + + /** Whether the formatter leaves the path out of the rule. */ + public function excludesFromRule(string $path, string $rule): bool + { + return self::matchesPath($this->ruleExcludePatterns[$rule] ?? [], $path); + } + + + /** @param list $patterns */ + private static function matchesPath(array $patterns, string $path): bool + { + return array_any($patterns, fn(string $regex) => preg_match($regex, "/$path") === 1 || preg_match($regex, $path) === 1); + } + + + /** + * The rules of the configuration as the decisions of DressCode, what could not be carried over said in its warnings; + * a configuration of PHP CS Fixer is run to give them. + * @throws ConfigurationException + */ + public function translate(): Translation + { + $foreign = match ($this->formatter) { + Formatter::PhpCsFixer => PhpCsFixer::readConfig($this->path), + Formatter::PhpCodeSniffer => PhpCodeSniffer::readConfig($this->path), + Formatter::Pint => Pint::readConfig($this->path), + }; + $translation = new Translator()->translate($foreign->rules, $foreign->indent, $foreign->lineEnding, $foreign->presets); + foreach ($foreign->warnings as $warning) { + $translation->warn($warning); + } + + foreach ($this->unread as $call) { + $translation->warn("`$call` of `$this->file` is not carried over; what the configuration checks may differ from what {$this->formatter->value} checked."); + } + + return $translation; + } +} diff --git a/src/Interop/FormatterSearch.php b/src/Interop/FormatterSearch.php new file mode 100644 index 0000000000..759fbb9128 --- /dev/null +++ b/src/Interop/FormatterSearch.php @@ -0,0 +1,183 @@ + + */ + public static function find(string $root): array + { + $candidates = self::collectCandidates($root); + $strongest = max(array_map(fn(FormatterCandidate $candidate) => $candidate->evidence->value, $candidates) ?: [0]); + return array_values(array_filter($candidates, fn(FormatterCandidate $candidate) => $candidate->evidence->value === $strongest)); + } + + + /** + * The configurations of formatters the project has, a file the scripts run before those in the root. + * @return list + */ + private static function collectCandidates(string $root): array + { + $composer = ComposerJson::read("$root/composer.json") ?? []; + $scripts = array_map(fn($commands) => array_filter((array) $commands, is_string(...)), (array) ($composer['scripts'] ?? [])); + $required = [...array_keys((array) ($composer['require'] ?? [])), ...array_keys((array) ($composer['require-dev'] ?? []))]; + $candidates = []; + foreach (array_keys($scripts) as $name) { + foreach (self::findRunConfigs($root, $scripts, (string) $name) as [$formatter, $file]) { + $candidates[$file] ??= new FormatterCandidate($formatter, $file, "composer $name", Evidence::Script); + } + } + + foreach (Formatter::cases() as $formatter) { + $file = self::findRootFile($root, $formatter); + if ($file !== null) { + $candidates[$file] ??= new FormatterCandidate( + $formatter, + $file, + null, + array_intersect($formatter->getPackages(), $required) ? Evidence::Package : Evidence::File, + ); + } + } + + return array_values($candidates); + } + + + /** + * The configurations the script runs a formatter with, through the scripts it calls by `@name` too: the file its + * option names, or the one the formatter finds in the root. + * @param array> $scripts + * @param array $visited + * @return list + */ + private static function findRunConfigs(string $root, array $scripts, string $name, array $visited = []): array + { + $found = []; + $visited[$name] = true; + foreach ($scripts[$name] ?? [] as $command) { + foreach (self::splitShellCommands($command) as $words) { + $called = substr($words[0], 1); + if (str_starts_with($words[0], '@') && isset($scripts[$called]) && !isset($visited[$called])) { + array_push($found, ...self::findRunConfigs($root, $scripts, $called, $visited)); + continue; + } + + $i = self::findProgram($words); + $formatter = Formatter::fromCommand($words[$i] ?? ''); + if ($formatter === null) { + continue; + } + + $option = $formatter->getConfigOption(); + $file = self::findRootFile($root, $formatter); + foreach (array_slice($words, $i + 1) as $j => $argument) { + $file = match (true) { + str_starts_with($argument, "$option=") => substr($argument, strlen($option) + 1), + $argument === $option => $words[$i + $j + 2] ?? null, + default => $file, + }; + } + + if ($file !== null && is_file("$root/$file")) { + $found[] = [$formatter, Helpers::canonicalizePath(FileSystem::normalizePath($file))]; + } + } + } + + return $found; + } + + + /** + * Where among the words of a command the program it runs stands, past the variables of its environment and a PHP + * or `composer exec` running it, so that a formatter a command only names, `echo pint`, is not run by it. + * @param non-empty-list $words + */ + private static function findProgram(array $words): int + { + $i = 0; + while (preg_match('~^\w+=~', $words[$i] ?? '')) { + $i++; + } + + $launcher = basename(strtr($words[$i] ?? '', '\\', '/')); + if ($launcher === '@php' || preg_match('~^php[\d.]*(\.exe)?$~', $launcher)) { + $i++; + } elseif (in_array($launcher, ['@composer', 'composer'], true) && ($words[$i + 1] ?? null) === 'exec') { + $i += 2; + } else { + return $i; + } + + while (str_starts_with($words[$i] ?? '', '-')) { + $i += in_array($words[$i], ['-d', '-c'], true) ? 2 : 1; // the options of PHP that take a value apart + } + + return $i; + } + + + private static function findRootFile(string $root, Formatter $formatter): ?string + { + return array_find($formatter->getRootFiles(), fn(string $file) => is_file("$root/$file")); + } + + + /** + * The commands of a line of the shell, which `&&`, `||`, `;` and `|` separate, as their words with the quotes taken + * off; a quote is not escaped inside quotes, which a script of composer.json running a formatter does not need. + * @return list> + */ + private static function splitShellCommands(string $line): array + { + $commands = $words = []; + $word = null; + for ($i = 0, $length = strlen($line); $i <= $length; $i++) { + $char = $line[$i] ?? ' '; + if ($char === "'" || $char === '"') { + $end = strpos($line, $char, $i + 1); + $end = $end === false ? $length : $end; + $word .= substr($line, $i + 1, $end - $i - 1); + $i = $end; + continue; + } elseif (!ctype_space($char) && !str_contains('&|;', $char)) { + $word .= $char; + continue; + } elseif ($word !== null) { + $words[] = $word; + $word = null; + } + + if ($words && (!ctype_space($char) || $i === $length)) { + $commands[] = $words; + $words = []; + } + } + + return $commands; + } +} diff --git a/src/Interop/PhpCodeSniffer.php b/src/Interop/PhpCodeSniffer.php new file mode 100644 index 0000000000..ca9174d19e --- /dev/null +++ b/src/Interop/PhpCodeSniffer.php @@ -0,0 +1,877 @@ +|\Closure(array, Translation): mixed> sniff => its decisions, or what to + * set for its properties; every property is read through `??` so that a sniff with none translates too + */ + public static function getTranslations(): array + { + // the blank lines before a property, a constant or an enum case, `blankLines` counting those after a member of + // another kind and before a documented one for every member together + $memberSpacing = fn(string $place) => fn(array $o, Translation $t) => $t + ->setBlankLines( + "blankLines.betweenMembers.$place", + $o['minLinesCountBeforeWithoutComment'] ?? 0, + $o['maxLinesCountBeforeWithoutComment'] ?? 1, + ) + ->setBlankLines( + 'blankLines.betweenMemberKinds', + $o['minLinesCountBeforeWithoutComment'] ?? 0, + $o['maxLinesCountBeforeWithoutComment'] ?? 1, + ) + ->setBlankLines( + 'blankLines.beforeDocumentedMember', + $o['minLinesCountBeforeWithComment'] ?? 1, + $o['maxLinesCountBeforeWithComment'] ?? 1, + ); + + // the nullable and the place of null in a type, which the sniffs check only where their properties name them + $typeFormat = fn(string $sniff) => function (array $o, Translation $t) use ($sniff) { + if (!isset($o['shortNullable']) && !isset($o['nullPosition'])) { + $t->warn("`$sniff` without `shortNullable` or `nullPosition` checks nothing DressCode decides."); + } + + $values = array_filter([ + 'nullable' => isset($o['shortNullable']) ? ($o['shortNullable'] === 'yes' ? 'questionMark' : 'union') : null, + 'nullPosition' => $o['nullPosition'] ?? null, + ], fn($v) => $v !== null); + // the doc comments are checked alike where the sniff is told so + foreach (($o['enableForDocComments'] ?? false) ? ['types', 'phpdoc.types'] : ['types'] as $section) { + foreach ($values as $key => $value) { + $t->set("$section.$key", $value); + } + } + }; + + return [ + 'Generic.Arrays.DisallowLongArraySyntax' => ['literals.arraySyntax.array' => 'short'], + 'Generic.Arrays.DisallowShortArraySyntax' => ['literals.arraySyntax.array' => 'long'], + 'Generic.CodeAnalysis.RequireExplicitBooleanOperatorPrecedence' => ['expressions.explicitPrecedence' => 'required'], + 'Generic.CodeAnalysis.UnnecessaryFinalModifier' => ['classes.modifiers.implied' => 'forbidden'], + 'Generic.ControlStructures.InlineControlStructure' => ['braces.bracelessBody' => 'forbidden'], + 'Generic.Files.ByteOrderMark' => ['file.bom' => 'forbidden'], + 'Generic.Files.LineEndings' => ['file.lineEnding' => 'majority'], + // a `lineLimit` given alone is the limit the project names; the absolute one is a limit only where it is given too + 'Generic.Files.LineLength' => fn(array $o, Translation $t) => $t->setLineLength( + ($o['absoluteLineLimit'] ?? (isset($o['lineLimit']) ? 0 : 100)) ?: ($o['lineLimit'] ?? 80), + ) + ->setAll(['file.longLines' => 'forbidden', 'file.lineLengthExcept' => ['imports']]), + 'Generic.Formatting.DisallowMultipleStatements' => ['file.statementsPerLine' => 1], + 'Generic.Functions.FunctionCallArgumentSpacing' => ['spacing.comma' => 'spaced', 'spacing.alignment.comma' => 'tabs'], + 'Generic.Functions.FunctionCallArgumentSpacing.SpaceBeforeOpenBracket' => ['spacing.call' => 'compact'], + 'Generic.NamingConventions.CamelCapsFunctionName' => fn(array $o, Translation $t) => $t->setAll([ + 'naming.method' => 'camelCase', + 'naming.function' => 'camelCase', + ]), + 'Generic.NamingConventions.UpperCaseConstantName' => function (array $o, Translation $t) { + $t->warn('`Generic.NamingConventions.UpperCaseConstantName` also checks `define()`, which DressCode does not.'); + $t->set('naming.constant', 'UPPER_CASE'); + }, + 'Generic.NamingConventions.UpperCaseConstantName.ClassConstantNotUpperCase' => ['naming.constant' => 'UPPER_CASE'], + 'Generic.PHP.DeprecatedFunctions' => ['upgrading.php.deprecatedApi' => 'forbidden'], + 'Generic.PHP.DisallowShortOpenTag' => ['file.openingTag' => 'required'], + // an alias of PHP is written as its function, any other replacement is advice; the default of the sniff leaves out + // `delete`, which PHP has no function of + 'Generic.PHP.ForbiddenFunctions' => function (array $o, Translation $t) { + $t->cover('upgrading.api.replacedFunctions', 'upgrading.api.forbiddenFunctions'); + $replaced = $forbidden = []; + foreach ($o['forbiddenFunctions'] ?? ['sizeof' => 'count'] as $name => $replacement) { + $function = strtolower($name); // as the sniff reads it + $replacement = $replacement === 'null' ? null : $replacement; // the text a ruleset writes for no replacement + if ($replacement !== null && NoAliasFunctionsRule::isAlias($function, $replacement)) { + $replaced[$function] = $replacement; + } else { + $forbidden[$function] = $replacement === null ? null : "use `$replacement()` instead"; + } + } + + if ($replaced !== []) { + $t->set('upgrading.api.replacedFunctions', $replaced); + } + if ($forbidden !== []) { + $t->set('upgrading.api.forbiddenFunctions', Map::collapseBareNames($forbidden)); + } + }, + 'Generic.PHP.LowerCaseConstant' => ['casing.trueFalseNull' => 'lowercase'], + 'Generic.PHP.LowerCaseKeyword' => ['casing.keyword' => 'lowercase'], + // `array`, `self` and the like are keywords of a declaration + 'Generic.PHP.LowerCaseType' => [ + 'expressions.cast' => 'canonical', + 'casing.type' => 'lowercase', + 'casing.keyword' => 'lowercase', + ], + 'Generic.PHP.RequireStrictTypes' => ['file.strictTypes.declaration' => 'required', 'file.strictTypes.position' => 'nextLine'], + 'Generic.PHP.SAPIUsage' => fn(array $o, Translation $t) => $t->set( + 'upgrading.api.forbiddenFunctions', + ['php_sapi_name' => 'use `PHP_SAPI` instead'], + ), + 'Generic.Strings.UnnecessaryStringConcat' => fn(array $o, Translation $t) => $t->setAll([ + 'literals.concatenatedLiterals' => 'forbidden', + 'literals.concatenatedLiteralsOverLines' => !($o['allowMultiline'] ?? false), + 'literals.emptyStringConcatenation' => 'forbidden', + ]), + 'Generic.WhiteSpace.DisallowSpaceIndent' => ['indentation.unit' => 'tab'], + // spaces of a width the sniff does not say, four unless a sniff naming the width decides it, in either order + 'Generic.WhiteSpace.DisallowTabIndent' => fn(array $o, Translation $t) => $t->prefer('indentation.unit', '4 spaces'), + 'Generic.WhiteSpace.IncrementDecrementSpacing' => ['spacing.unaryOperator' => 'compact'], + 'Generic.WhiteSpace.LanguageConstructSpacing' => ['spacing.keyword' => 'required'], + 'Generic.WhiteSpace.ScopeIndent' => function (array $o, Translation $t) { + $unit = ($o['tabIndent'] ?? false) ? 'tab' : match ($o['indent'] ?? 4) { + 4 => '4 spaces', + 2 => '2 spaces', + default => null, + }; + if ($unit === null) { + $t->warn("`Generic.WhiteSpace.ScopeIndent` with `indent={$o['indent']}` has no equivalent; DressCode indents by a tab, four spaces or two."); + } elseif (isset($o['tabIndent']) || isset($o['indent'])) { + $t->set('indentation.unit', $unit); + } else { + // with its defaults the sniff says only the width of a level, so a sniff asking for tabs wins + $t->prefer('indentation.unit', $unit); + } + }, + 'PEAR.Commenting.InlineComment' => ['comments.singleline' => 'slashes'], + 'PEAR.Functions.ValidDefaultValue' => ['functions.unreachableParameterDefault' => 'forbidden'], + 'PEAR.WhiteSpace.ObjectOperatorIndent' => fn(array $o, Translation $t) => $t + ->set('indentation.chain', ($o['multilevel'] ?? false) ? 'nested' : 'flat'), + 'PSR1.Methods.CamelCapsMethodName' => fn(array $o, Translation $t) => $t->set('naming.method', 'camelCase'), + 'PSR1.Methods.CamelCapsMethodName.NotCamelCaps' => ['naming.method' => 'camelCase'], + 'PSR12.Classes.AnonClassDeclaration' => [ + 'braces.position.anonymousClass' => 'sameLine', + 'spacing.keyword' => 'required', + 'spacing.anonymousClass' => 'spaced', + 'multiline.items.implements' => 'perLine', + 'multiline.edges.implements' => 'apart', + ...Translation::NoSpaceInParentheses, + ], + 'PSR12.Classes.ClassInstantiation' => ['classes.emptyParentheses.instantiation' => 'required'], + 'PSR12.Classes.ClosingBrace' => function (array $o, Translation $t) { + $t->warn('`PSR12.Classes.ClosingBrace` also forbids a comment after the closing brace of a class or a function, which DressCode does not.'); + $t->set('file.statementsPerLine', 1); + }, + 'PSR12.Classes.OpeningBraceSpace' => fn(array $o, Translation $t) => $t->setAll([ + 'blankLines.bodyStart.class' => 0, + 'blankLines.bodyStart.anonymousClass' => 0, + ]), + 'PSR12.ControlStructures.BooleanOperatorPlacement' => function (array $o, Translation $t) { + $only = $o['allowOnly'] ?? null; + if ($only === 'first') { + $t->set('multiline.operatorPosition.condition', 'lineStart'); + } elseif ($only === 'last') { + $t->set('multiline.operatorPosition.condition', 'lineEnd'); + } else { + $t->warn('`PSR12.ControlStructures.BooleanOperatorPlacement` forbids a condition mixing boolean operators at the start and at the end of its lines, which DressCode does not check.'); + $t->keep('multiline.operatorPosition.condition'); + } + }, + 'PSR12.ControlStructures.ControlStructureSpacing' => function (array $o, Translation $t) { + $t->warn('`PSR12.ControlStructures.ControlStructureSpacing` also puts the parentheses of a multi-line `for`, `foreach`, `switch`, `catch` and `match` on lines of their own, which DressCode does not.'); + $t->setAll([ + ...Translation::NoSpaceInParentheses, + 'multiline.items.condition' => ['perLine', 'fill'], + 'multiline.edges.condition' => 'apart', + ]); + }, + 'PSR12.Files.DeclareStatement' => function (array $o, Translation $t) { + $t->warn('`PSR12.Files.DeclareStatement` also checks the case of the directive of a `declare`, which DressCode does not.'); + $t->setAll(['spacing.declare' => 'compact', 'braces.position.controlStructure' => 'sameLine']); + }, + 'PSR12.Files.FileHeader' => [ + 'blankLines.afterOpeningTag' => 1, + 'blankLines.afterDeclare' => 1, + 'blankLines.beforeNamespace' => 1, + 'blankLines.afterNamespace' => 1, + 'blankLines.afterImports' => 1, + 'blankLines.beforeDeclaration' => 1, + 'blankLines.betweenImports' => 0, + 'blankLines.betweenImportKinds' => 1, + ], + 'PSR12.Files.ImportStatement' => fn(array $o, Translation $t) => $t->set('imports.leadingBackslash', 'forbidden'), + 'PSR12.Files.OpenTag' => function (array $o, Translation $t) { + $t->warn('`PSR12.Files.OpenTag` leaves the line below the opening tag as it is, where DressCode puts a blank line.'); + $t->set('blankLines.afterOpeningTag', 1); + }, + 'PSR12.Functions.NullableTypeDeclaration' => ['spacing.typeDeclaration' => 'compact'], + 'PSR12.Functions.ReturnTypeDeclaration' => ['spacing.typeDeclaration' => 'compact'], + 'PSR12.Keywords.ShortFormTypeKeywords' => ['expressions.cast' => 'canonical'], + 'PSR12.Operators.OperatorSpacing' => fn(array $o, Translation $t) => $t->setAll([ + 'spacing.binaryOperator' => 'spaced', + 'spacing.alignment.binaryOperator' => 'spaces', + 'spacing.ternary' => 'spaced', + 'spacing.alignment.ternary' => 'optional', + 'spacing.concatenation' => 'spaced', + ]), + 'PSR12.Properties.ConstantVisibility' => [ + 'classes.visibility.constant' => 'required', + 'classes.visibility.property' => 'required', + 'classes.visibility.method' => 'required', + ], + 'PSR12.Traits.UseDeclaration' => function (array $o, Translation $t) { + $t->warn('`PSR12.Traits.UseDeclaration` also lays out the conflict block of a trait use, its opening brace on the line of `use` and a rule per line, which DressCode does not.'); + $t->setAll([ + 'blankLines.bodyStart.class' => 0, + 'blankLines.betweenTraitUses' => 0, + 'blankLines.afterTraitUses' => 1, + 'blankLines.bodyEnd.class' => 0, + // the space after `use`, around `as` and `insteadof`, and none before the semicolon + 'spacing.keyword' => 'required', + 'spacing.semicolon.before' => 'compact', + ]); + $t->set('classes.members.grouping.traitUse', 'separated'); + // a full order another rule sets puts the trait uses where it says + $t->prefer('classes.members.order', ['traitUse']); + }, + 'PSR2.Classes.ClassDeclaration' => [ + 'braces.position.class' => 'nextLine', + 'spacing.keyword' => 'required', + 'multiline.items.implements' => 'perLine', + 'multiline.edges.implements' => 'apart', + ], + 'PSR2.Classes.ClassDeclaration.SpaceBeforeKeyword' => ['spacing.keyword' => 'required'], + 'PSR2.Classes.PropertyDeclaration' => [ + 'classes.visibility.constant' => 'required', + 'classes.visibility.property' => 'required', + 'classes.visibility.method' => 'required', + 'classes.modifiers.order' => 'canonical', + ], + 'PSR2.ControlStructures.ControlStructureSpacing' => Translation::NoSpaceInParentheses, + 'PSR2.ControlStructures.ElseIfDeclaration' => ['controlFlow.elseif' => 'oneWord'], + 'PSR2.ControlStructures.SwitchDeclaration' => function (array $o, Translation $t) { + $t->warn('`PSR2.ControlStructures.SwitchDeclaration` also starts the body of a case on the line below it and forbids braces around it, which DressCode does not.'); + $t->setAll([ + 'upgrading.php.caseSemicolon' => 'forbidden', + 'spacing.switchCase' => 'compact', + 'controlFlow.switch.fallThroughComment' => 'no break', + 'casing.keyword' => 'lowercase', + 'spacing.keyword' => 'required', + 'file.statementsPerLine' => 1, + 'indentation.switchCase' => 1, + ]); + }, + 'PSR2.ControlStructures.SwitchDeclaration.SpaceBeforeColonCASE' => ['spacing.switchCase' => 'compact'], + 'PSR2.Files.ClosingTag' => ['file.closingTagAtEnd' => 'forbidden'], + 'PSR2.Files.EndFileNewline' => ['file.finalLineEndings' => 1], + 'PSR2.Methods.FunctionCallSignature' => function (array $o, Translation $t) { + if (($o['requiredSpacesAfterOpen'] ?? 0) > 0 || ($o['requiredSpacesBeforeClose'] ?? 0) > 0) { + $t->warn('`PSR2.Methods.FunctionCallSignature` with spaces inside the parentheses has no equivalent; DressCode writes none.'); + } + $t->setAll([ + 'spacing.call' => 'compact', + ...Translation::NoSpaceInParentheses, + 'multiline.items.argument' => ($o['allowMultipleArguments'] ?? false) ? 'fill' : 'perLine', + 'multiline.edges.argument' => 'apart', + ]); + }, + 'PSR2.Methods.FunctionClosingBrace' => fn(array $o, Translation $t) => $t->setAll([ + 'blankLines.bodyEnd.function' => 0, + 'blankLines.bodyEnd.closure' => 0, + ]), + 'PSR2.Methods.MethodDeclaration' => function (array $o, Translation $t) { + $t->warn('`PSR2.Methods.MethodDeclaration` also rules on the underscore prefix of method names, which DressCode does not.'); + $t->set('classes.modifiers.order', 'canonical'); + }, + 'PSR2.Namespaces.NamespaceDeclaration' => ['blankLines.afterNamespace' => 1, 'blankLines.beforeDeclaration' => 1], + 'PSR2.Namespaces.UseDeclaration' => [ + 'blankLines.afterImports' => 1, + 'blankLines.beforeDeclaration' => 1, + 'imports.grouping.class' => 'separated', + 'imports.grouping.function' => 'separated', + 'imports.grouping.constant' => 'separated', + ], + 'SlevomatCodingStandard.Arrays.MultiLineArrayEndBracketPlacement' => ['multiline.edges.array' => ['apart', 'attached']], + 'SlevomatCodingStandard.Arrays.SingleLineArrayWhitespace' => Translation::NoSpaceInArrays, + 'SlevomatCodingStandard.Arrays.TrailingArrayComma' => fn(array $o, Translation $t) => $t->set( + 'multiline.trailingComma.array', 'required', + ), + 'SlevomatCodingStandard.Attributes.AttributeAndTargetSpacing' => fn(array $o, Translation $t) => $t->set( + 'blankLines.afterAttributes', + $o['linesCountBetweenAttributeAndTarget'] ?? 0, + ), + 'SlevomatCodingStandard.Attributes.DisallowMultipleAttributesPerLine' => [ + 'multiline.attributePosition.declaration' => 'above', + 'multiline.attributePosition.parameter' => 'above', + ], + 'SlevomatCodingStandard.Attributes.RequireAttributeAfterDocComment' => ['phpdoc.aboveAttributes' => 'required'], + 'SlevomatCodingStandard.Classes.BackedEnumTypeSpacing' => [ + 'spacing.typeDeclaration' => 'compact', + ], + 'SlevomatCodingStandard.Classes.ClassConstantVisibility' => [ + 'classes.visibility.constant' => 'required', + 'classes.visibility.property' => 'required', + 'classes.visibility.method' => 'required', + ], + // only between members of two kinds, a trait use, a constant, a property, a method + 'SlevomatCodingStandard.Classes.ClassMemberSpacing' => fn(array $o, Translation $t) => $t + ->setBlankLines('blankLines.betweenMemberKinds', $o['linesCountBetweenMembers'] ?? 1, $o['linesCountBetweenMembers'] ?? 1), + 'SlevomatCodingStandard.Classes.ConstantSpacing' => $memberSpacing('constant'), + 'SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition' => ['classes.members.grouping.constant' => 'separated'], + 'SlevomatCodingStandard.Classes.DisallowMultiPropertyDefinition' => ['classes.members.grouping.property' => 'separated'], + 'SlevomatCodingStandard.Classes.EmptyLinesAroundClassBraces' => fn(array $o, Translation $t) => $t->setAll([ + 'blankLines.bodyStart.class' => $o['linesCountAfterOpeningBrace'] ?? 1, + 'blankLines.bodyStart.anonymousClass' => $o['linesCountAfterOpeningBrace'] ?? 1, + 'blankLines.bodyEnd.class' => $o['linesCountBeforeClosingBrace'] ?? 1, + 'blankLines.bodyEnd.anonymousClass' => $o['linesCountBeforeClosingBrace'] ?? 1, + ]), + 'SlevomatCodingStandard.Classes.EnumCaseSpacing' => $memberSpacing('enumCase'), + 'SlevomatCodingStandard.Classes.MethodSpacing' => fn(array $o, Translation $t) => $t + ->setBlankLines('blankLines.aroundMethods', $o['minLinesCount'] ?? 1, $o['maxLinesCount'] ?? 1) + ->setBlankLines('blankLines.aroundInterfaceMethods', $o['minLinesCount'] ?? 1, $o['maxLinesCount'] ?? 1), + 'SlevomatCodingStandard.Classes.ModernClassNameReference' => fn(array $o, Translation $t) => $t->setAll([ + 'hygiene.classNameNotation.currentClass' => 'required', + 'hygiene.classNameNotation.object' => ($o['enableOnObjects'] ?? false) ? 'required' : 'keep', + ]), + 'SlevomatCodingStandard.Classes.PropertyDeclaration' => [ + 'classes.modifiers.order' => 'canonical', + ], + 'SlevomatCodingStandard.Classes.PropertySpacing' => $memberSpacing('property'), + 'SlevomatCodingStandard.Classes.RequireConstructorPropertyPromotion' => fn(array $o, Translation $t) => ($o['enable'] ?? true) + ? $t->set('upgrading.syntax.promotedProperties', 'required') + : $t->keep('upgrading.syntax.promotedProperties'), + 'SlevomatCodingStandard.Classes.RequireMultiLineMethodSignature' => function (array $o, Translation $t) { + if (($min = $o['minLineLength'] ?? 121) > 1) { + $t->setLineLength($min - 1); + } else { + $t->warn("`SlevomatCodingStandard.Classes.RequireMultiLineMethodSignature` with `minLineLength=$min` has no equivalent; DressCode spreads over lines only a signature longer than the line."); + } + + $t->setAll([ + 'multiline.items.parameter' => 'perLine', + 'multiline.edges.parameter' => 'apart', + 'multiline.spreadAlways' => ($o['withPromotedProperties'] ?? false) ? ['promotedProperty'] : [], + ]); + }, + 'SlevomatCodingStandard.Classes.SuperfluousAbstractClassNaming' => fn(array $o, Translation $t) => $t->set( + 'naming.abstractClassAffix', 'forbidden', + ), + 'SlevomatCodingStandard.Classes.SuperfluousErrorNaming' => fn(array $o, Translation $t) => $t->set( + 'naming.errorAffix', 'forbidden', + ), + 'SlevomatCodingStandard.Classes.SuperfluousExceptionNaming' => fn(array $o, Translation $t) => $t->set( + 'naming.exceptionAffix', 'forbidden', + ), + 'SlevomatCodingStandard.Classes.SuperfluousInterfaceNaming' => fn(array $o, Translation $t) => $t->set( + 'naming.interfaceAffix', 'forbidden', + ), + 'SlevomatCodingStandard.Classes.SuperfluousTraitNaming' => fn(array $o, Translation $t) => $t->set( + 'naming.traitAffix', 'forbidden', + ), + 'SlevomatCodingStandard.Classes.TraitUseDeclaration' => ['classes.members.grouping.traitUse' => 'separated'], + 'SlevomatCodingStandard.Classes.TraitUseSpacing' => fn(array $o, Translation $t) => $t->setAll([ + 'blankLines.betweenTraitUses' => $o['linesCountBetweenUses'] ?? 0, + 'blankLines.afterTraitUses' => $o['linesCountAfterLastUse'] ?? 1, + ]), + 'SlevomatCodingStandard.Classes.UselessLateStaticBinding' => fn(array $o, Translation $t) => $t->setAll([ + 'qualification.currentClass' => 'self', + 'qualification.staticInFinalClass' => 'required', + ]), + 'SlevomatCodingStandard.Commenting.AnnotationName' => ['phpdoc.annotation.casing' => 'canonical'], + 'SlevomatCodingStandard.Commenting.DisallowOneLinePropertyDocComment' => ['phpdoc.property.lineSpan' => 'expanded'], + 'SlevomatCodingStandard.Commenting.EmptyComment' => ['comments.empty' => 'forbidden'], + 'SlevomatCodingStandard.Commenting.ForbiddenAnnotations' => fn(array $o, Translation $t) => $t->set( + 'phpdoc.forbiddenAnnotations', + ($o['forbiddenAnnotations'] ?? []) ?: 'keep', + ), + 'SlevomatCodingStandard.Commenting.ForbiddenComments' => fn(array $o, Translation $t) => $t->set( + 'phpdoc.forbiddenLines', + $o['forbiddenCommentPatterns'] ?? [], + ), + 'SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.InvalidCommentType' => ['phpdoc.annotation.plainComment' => 'forbidden'], + 'SlevomatCodingStandard.Commenting.RequireOneLinePropertyDocComment' => ['phpdoc.property.lineSpan' => 'collapsed'], + 'SlevomatCodingStandard.Commenting.UselessFunctionDocComment' => ['phpdoc.annotation.nativeTypeOnly' => 'forbidden'], + 'SlevomatCodingStandard.Commenting.UselessInheritDocComment' => ['phpdoc.inheritdocOnly' => 'forbidden'], + 'SlevomatCodingStandard.ControlStructures.BlockControlStructureSpacing' => function (array $o, Translation $t) { + $supported = ['if', 'do', 'while', 'for', 'foreach', 'switch', 'try']; + $unsupported = array_diff($o['controlStructures'] ?? [], $supported); + if ($unsupported) { + $t->warn('`SlevomatCodingStandard.ControlStructures.BlockControlStructureSpacing` with `' . implode('`, `', $unsupported) . '` has no equivalent; DressCode counts the blank lines before a `case` in `blankLines.betweenCases`.'); + } + + $statements = array_values(array_intersect($o['controlStructures'] ?? $supported, $supported)); + if ($statements) { + $t->setAll([ + 'blankLines.beforeStatement' => array_fill_keys($statements, $o['linesCountBefore'] ?? 1), + 'blankLines.afterStatement' => array_fill_keys($statements, $o['linesCountAfter'] ?? 1), + ]); + } + }, + 'SlevomatCodingStandard.ControlStructures.DisallowContinueWithoutIntegerOperandInSwitch' => function (array $o, Translation $t) { + $t->warn('`SlevomatCodingStandard.ControlStructures.DisallowContinueWithoutIntegerOperandInSwitch` is only reported by DressCode, which does not rewrite the `continue`.'); + $t->set('mistakes.continueInSwitch', 'forbidden'); + }, + 'SlevomatCodingStandard.ControlStructures.DisallowShortTernaryOperator' => ['expressions.ternary.short' => 'forbidden'], + 'SlevomatCodingStandard.ControlStructures.DisallowTrailingMultiLineTernaryOperator' => ['multiline.operatorPosition.ternary' => 'lineStart'], + 'SlevomatCodingStandard.ControlStructures.DisallowYodaComparison' => ['expressions.comparison.yoda' => 'forbidden'], + 'SlevomatCodingStandard.ControlStructures.RequireYodaComparison' => ['expressions.comparison.yoda' => 'required'], + 'SlevomatCodingStandard.ControlStructures.EarlyExit' => function (array $o, Translation $t) { + foreach (['ignoreStandaloneIfInScope', 'ignoreOneLineTrailingIf'] as $property) { + if ($o[$property] ?? false) { + $t->warn("`SlevomatCodingStandard.ControlStructures.EarlyExit` with `$property=true` has no equivalent; DressCode makes a guard of every trailing `if` that has enough statements."); + } + } + + $t->setAll([ + 'controlFlow.trailingIf' => 'forbidden', + 'controlFlow.trailingIfMinStatements' => ($o['ignoreTrailingIfWithOneInstruction'] ?? false) ? 2 : 1, + 'controlFlow.elseAfterReturn' => 'forbidden', + 'controlFlow.elseifAfterReturn' => 'forbidden', + ]); + }, + 'SlevomatCodingStandard.ControlStructures.JumpStatementsSpacing' => fn(array $o, Translation $t) => $t->setAll([ + 'blankLines.beforeStatement' => array_fill_keys( + array_intersect( + $o['jumpStatements'] ?? ['break', 'continue', 'return', 'throw', 'yield'], + ['break', 'continue', 'return', 'throw', 'yield'], + ), + $o['linesCountBefore'] ?? 1, + ), + 'blankLines.afterStatement' => array_fill_keys( + array_intersect( + $o['jumpStatements'] ?? ['break', 'continue', 'return', 'throw', 'yield'], + ['break', 'continue', 'return', 'throw', 'yield'], + ), + $o['linesCountAfter'] ?? 1, + ), + ]), + 'SlevomatCodingStandard.ControlStructures.LanguageConstructWithParentheses' => ['expressions.parenthesesAfterConstruct' => 'forbidden'], + 'SlevomatCodingStandard.ControlStructures.NewWithParentheses' => ['classes.emptyParentheses.instantiation' => 'required'], + 'SlevomatCodingStandard.ControlStructures.NewWithoutParentheses' => ['classes.emptyParentheses.instantiation' => 'forbidden'], + 'SlevomatCodingStandard.ControlStructures.RequireMultiLineCondition' => function (array $o, Translation $t) { + if (($min = $o['minLineLength'] ?? 121) > 1) { + $t->setLineLength($min - 1); + } else { + $t->warn("`SlevomatCodingStandard.ControlStructures.RequireMultiLineCondition` with `minLineLength=$min` has no equivalent; DressCode spreads over lines only a condition longer than the line."); + } + + $t->setAll([ + 'multiline.items.condition' => ($o['alwaysSplitAllConditionParts'] ?? false) ? 'perLine' : ['perLine', 'fill'], + 'multiline.edges.condition' => ($o['alwaysSplitAllConditionParts'] ?? false) ? 'apart' : 'closingApart', + ]); + }, + 'SlevomatCodingStandard.ControlStructures.RequireMultiLineTernaryOperator' => function (array $o, Translation $t) { + if (($limit = $o['lineLengthLimit'] ?? 0) > 0) { + $t->setLineLength($limit); + } else { + $t->warn('`SlevomatCodingStandard.ControlStructures.RequireMultiLineTernaryOperator` with `lineLengthLimit=0` has no equivalent; DressCode spreads over lines only a ternary longer than the line.'); + } + + if (isset($o['minExpressionsLength'])) { + $t->warn('`SlevomatCodingStandard.ControlStructures.RequireMultiLineTernaryOperator` with `minExpressionsLength` has no equivalent; DressCode does not measure the expressions of a ternary.'); + } + + $t->set('multiline.items.ternary', 'perLine'); + }, + 'SlevomatCodingStandard.ControlStructures.RequireNullCoalesceEqualOperator' => ['expressions.assignment.targetInValue' => 'forbidden'], + 'SlevomatCodingStandard.ControlStructures.RequireNullCoalesceOperator' => ['expressions.coalesce' => 'required'], + 'SlevomatCodingStandard.ControlStructures.RequireNullSafeObjectOperator' => fn(array $o, Translation $t) => ($o['enable'] ?? true) + ? $t->set('upgrading.syntax.nullsafe', 'required') + : $t->keep('upgrading.syntax.nullsafe'), + 'SlevomatCodingStandard.ControlStructures.RequireShortTernaryOperator' => ['expressions.ternary.short' => 'required'], + 'SlevomatCodingStandard.ControlStructures.RequireTernaryOperator' => ['controlFlow.ifElseReturning' => 'forbidden'], + 'SlevomatCodingStandard.ControlStructures.UselessIfConditionWithReturn' => ['controlFlow.ifReturningTrueFalse' => 'forbidden'], + 'SlevomatCodingStandard.ControlStructures.UselessTernaryOperator' => ['expressions.ternary.ofTrueFalse' => 'forbidden'], + 'SlevomatCodingStandard.Exceptions.DeadCatch' => ['mistakes.unreachableCatch' => 'forbidden'], + 'SlevomatCodingStandard.Exceptions.ReferenceThrowableOnly' => ['mistakes.exceptionWhereThrowableBelongs' => 'forbidden'], + 'SlevomatCodingStandard.Exceptions.RequireNonCapturingCatch' => ['hygiene.unreadCatchVariable' => 'forbidden'], + 'SlevomatCodingStandard.Files.LineLength' => fn(array $o, Translation $t) => $t->setLineLength($o['lineLengthLimit'] ?? 120) + ->setAll([ + 'file.longLines' => 'forbidden', + 'file.lineLengthExcept' => ($o['ignoreImports'] ?? true) ? ['imports'] : [], + ]), + 'SlevomatCodingStandard.Functions.ArrowFunctionDeclaration' => fn(array $o, Translation $t) => match ($o['spacesCountAfterKeyword'] ?? 1) { + 0 => $t->set('spacing.arrowFunction', 'compact'), + 1 => $t->set('spacing.arrowFunction', 'spaced'), + default => $t->warn("`SlevomatCodingStandard.Functions.ArrowFunctionDeclaration` with `spacesCountAfterKeyword={$o['spacesCountAfterKeyword']}` has no equivalent; DressCode writes a single space after `fn` or none."), + }, + 'SlevomatCodingStandard.Functions.NamedArgumentSpacing' => ['spacing.namedArgument' => 'spacedAfter'], + 'SlevomatCodingStandard.Functions.RequireArrowFunction' => fn(array $o, Translation $t) => $t->setAll([ + 'functions.arrowFunction.oneExpression' => 'required', + 'functions.arrowFunctionNested' => $o['allowNested'] ?? true, + ]), + 'SlevomatCodingStandard.Functions.RequireTrailingCommaInCall' => fn(array $o, Translation $t) => $t->set( + 'multiline.trailingComma.argument', 'required', + ), + 'SlevomatCodingStandard.Functions.RequireTrailingCommaInDeclaration' => fn(array $o, Translation $t) => $t->set( + 'multiline.trailingComma.parameter', 'required', + ), + 'SlevomatCodingStandard.Functions.StaticClosure' => ['functions.staticClosure' => 'required'], + 'SlevomatCodingStandard.Functions.StrictCall' => ['mistakes.strictArgument' => 'required'], + 'SlevomatCodingStandard.Functions.UselessParameterDefaultValue' => ['functions.unreachableParameterDefault' => 'forbidden'], + 'SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses' => fn(array $o, Translation $t) => $t->setAll([ + 'imports.order.names' => 'alphabetical', + 'imports.order.kinds' => ($o['psr12Compatible'] ?? true) ? ['class', 'function', 'constant'] : 'keep', + 'imports.order.caseSensitive' => $o['caseSensitive'] ?? false, + ]), + 'SlevomatCodingStandard.Namespaces.DisallowGroupUse' => function (array $o, Translation $t) { + foreach (['class', 'function', 'constant'] as $kind) { + $t->setAllowed("imports.grouping.$kind", ['separated', 'combined']); + } + }, + 'SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalConstants' => function (array $o, Translation $t) { + $named = [...$o['include'] ?? [], ...$o['exclude'] ?? []]; + if ($named !== []) { + $t->warn('`SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalConstants` names constants one by one (`' . implode('`, `', $named) . '`), which DressCode decides by group; a constant named follows `qualification.constant.global`.'); + } + + // only an include left empty names every constant + if (($o['include'] ?? []) === []) { + $t->setQualificationShape(['globalConstant' => 'backslashed']); + $t->setQualificationFallback(['constant' => 'qualified']); + } + }, + 'SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalFunctions' => function (array $o, Translation $t) { + $named = [...$o['include'] ?? [], ...$o['exclude'] ?? []]; + if ($named !== []) { + $t->warn('`SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalFunctions` names functions one by one (`' . implode('`, `', $named) . '`), which DressCode decides by group; a function named follows `qualification.function.global`, or `qualification.function.optimized` where the compiler optimizes it.'); + } + + // the special functions join the include, and only an include left empty names every function + if ($o['includeSpecialFunctions'] ?? false) { + $t->setQualificationShape(['optimizedFunction' => 'backslashed']); + $t->setQualificationFallback(['optimizedFunction' => 'qualified']); + } elseif (($o['include'] ?? []) === []) { + $t->setQualificationShape(['globalFunction' => 'backslashed']); + $t->setQualificationFallback(['function' => 'qualified']); + } + }, + // a comma between the names of a group use is one too + 'SlevomatCodingStandard.Namespaces.MultipleUsesPerLine' => [ + 'imports.grouping.class' => 'separated', + 'imports.grouping.function' => 'separated', + 'imports.grouping.constant' => 'separated', + ], + 'SlevomatCodingStandard.Namespaces.NamespaceSpacing' => fn(array $o, Translation $t) => $t->setAll([ + 'blankLines.beforeNamespace' => $o['linesCountBeforeNamespace'] ?? 1, + 'blankLines.afterNamespace' => $o['linesCountAfterNamespace'] ?? 1, + ]), + 'SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly' => function (array $o, Translation $t) { + // a global name written with the backslash may stay so, and a bare one may stand on the fallback, or else it is imported + $shape = fn(bool $backslash) => $backslash ? 'keep' : 'imported'; + $t->setQualificationShape([ + 'class' => 'imported', + 'globalClass' => ($o['allowFullyQualifiedGlobalClasses'] ?? false) ? 'keep' : 'imported', + 'function' => 'imported', + 'globalFunction' => $shape($o['allowFullyQualifiedGlobalFunctions'] ?? false), + 'constant' => 'imported', + 'globalConstant' => $shape($o['allowFullyQualifiedGlobalConstants'] ?? false), + ]); + $fallback = array_filter([ + 'function' => ($o['allowFallbackGlobalFunctions'] ?? true) ? null : 'qualified', + 'constant' => ($o['allowFallbackGlobalConstants'] ?? true) ? null : 'qualified', + ]); + $t->setQualificationFallback($fallback); + if (($o['allowPartialUses'] ?? true) === false) { + $t->warn('`SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` with `allowPartialUses=false` has no equivalent; DressCode lets a partial name stand.'); + } + }, + 'SlevomatCodingStandard.Namespaces.UnusedUses' => function (array $o, Translation $t) { + if (!($o['searchAnnotations'] ?? false)) { + $t->warn('`SlevomatCodingStandard.Namespaces.UnusedUses` without `searchAnnotations` removes an import only a doc comment uses; DressCode keeps it.'); + } + + $t->set('imports.unused', 'forbidden'); + }, + 'SlevomatCodingStandard.Namespaces.UseDoesNotStartWithBackslash' => fn(array $o, Translation $t) => $t->set( + 'imports.leadingBackslash', 'forbidden', + ), + 'SlevomatCodingStandard.Namespaces.UseFromSameNamespace' => ['imports.fromCurrentNamespace' => 'forbidden'], + 'SlevomatCodingStandard.Namespaces.UselessAlias' => ['imports.aliasEqualToName' => 'forbidden'], + 'SlevomatCodingStandard.Numbers.DisallowNumericLiteralSeparator' => [ + 'literals.digitSeparator.integer' => 'forbidden', + 'literals.digitSeparator.fraction' => 'forbidden', + ], + 'SlevomatCodingStandard.Numbers.RequireNumericLiteralSeparator' => fn(array $o, Translation $t) => $t->setAll([ + 'literals.digitSeparator.integer' => 'required', + 'literals.digitSeparator.fraction' => 'required', + 'literals.digitSeparatorMinDigits.integer' => $o['minDigitsBeforeDecimalPoint'] ?? 4, + 'literals.digitSeparatorMinDigits.fraction' => $o['minDigitsAfterDecimalPoint'] ?? 4, + ]), + 'SlevomatCodingStandard.Operators.DisallowEqualOperators' => ['expressions.comparison.loose' => 'forbidden'], + 'SlevomatCodingStandard.Operators.NegationOperatorSpacing' => fn(array $o, Translation $t) => $t->set( + 'spacing.logicalNot', + ($o['spacesCount'] ?? 0) === 0 ? 'compact' : 'spaced', + ), + 'SlevomatCodingStandard.Operators.RequireCombinedAssignmentOperator' => ['expressions.assignment.targetInValue' => 'forbidden'], + 'SlevomatCodingStandard.Operators.SpreadOperatorSpacing' => ['spacing.spread' => 'compact'], + 'SlevomatCodingStandard.PHP.DisallowDirectMagicInvokeCall' => ['hygiene.__invokeCall' => 'forbidden'], + 'SlevomatCodingStandard.PHP.OptimizedFunctionsWithoutUnpacking' => fn(array $o, Translation $t) => $t->markCallsOptimized(), + 'SlevomatCodingStandard.PHP.ReferenceSpacing' => ['spacing.reference' => 'compact'], + 'SlevomatCodingStandard.PHP.RequireExplicitAssertion' => ['phpdoc.annotation.inlineVar' => 'forbidden'], + 'SlevomatCodingStandard.PHP.RequireNowdoc' => ['literals.heredocWithoutInterpolation' => 'forbidden'], + 'SlevomatCodingStandard.PHP.ShortList' => ['literals.arraySyntax.list' => 'short'], + 'SlevomatCodingStandard.PHP.TypeCast' => ['expressions.cast' => 'canonical'], + 'SlevomatCodingStandard.PHP.UselessSemicolon' => ['controlFlow.emptyStatement' => 'forbidden'], + 'SlevomatCodingStandard.TypeHints.DeclareStrictTypes' => function (array $o, Translation $t) { + if (($o['spacesCountAroundEqualsSign'] ?? 1) !== 0) { + $t->warn('`SlevomatCodingStandard.TypeHints.DeclareStrictTypes` with spaces around the equals sign has no equivalent; DressCode writes `declare(strict_types=1)` without spaces.'); + } + $t->setAll([ + 'file.strictTypes.declaration' => 'required', + 'file.strictTypes.position' => ($o['declareOnFirstLine'] ?? false) ? 'sameLine' : 'nextLine', + ]); + }, + 'SlevomatCodingStandard.TypeHints.DisallowArrayTypeHintSyntax' => fn(array $o, Translation $t) => $t->set( + 'phpdoc.types.array', 'generic', + ), + 'SlevomatCodingStandard.TypeHints.DNFTypeHintFormat' => $typeFormat('SlevomatCodingStandard.TypeHints.DNFTypeHintFormat'), + 'SlevomatCodingStandard.TypeHints.LongTypeHints' => fn(array $o, Translation $t) => $t->set( + 'phpdoc.types.builtin', 'canonical', + ), + 'SlevomatCodingStandard.TypeHints.NullTypeHintOnLastPosition' => fn(array $o, Translation $t) => $t->set( + 'phpdoc.types.nullPosition', 'last', + ), + 'SlevomatCodingStandard.TypeHints.NullableTypeForNullDefaultValue' => ['upgrading.php.implicitNullable' => 'forbidden'], + 'SlevomatCodingStandard.TypeHints.ParameterTypeHint' => fn(array $o, Translation $t) => $t + ->setAll(array_filter([ + 'types.declaration.parameter' => 'required', + 'phpdoc.annotation.nativeTypeOnly' => 'forbidden', + 'phpdoc.annotation.itemTypes' => 'required', + 'types.traversableClasses' => $o['traversableTypeHints'] ?? null, + ], fn($v) => $v !== null)), + 'SlevomatCodingStandard.TypeHints.ParameterTypeHintSpacing' => [ + 'spacing.typeDeclaration' => 'compact', + ], + 'SlevomatCodingStandard.TypeHints.PropertyTypeHint' => fn(array $o, Translation $t) => $t + ->setAll(array_filter([ + 'types.declaration.property' => 'required', + 'phpdoc.annotation.nativeTypeOnly' => 'forbidden', + 'phpdoc.annotation.itemTypes' => 'required', + 'types.traversableClasses' => $o['traversableTypeHints'] ?? null, + ], fn($v) => $v !== null)), + 'SlevomatCodingStandard.TypeHints.ReturnTypeHint' => fn(array $o, Translation $t) => $t + ->setAll(array_filter([ + 'types.declaration.return' => 'required', + 'types.declaration.anonymousFunction' => ($o['enableClosure'] ?? true) ? 'required' : null, + 'phpdoc.annotation.nativeTypeOnly' => 'forbidden', + 'phpdoc.annotation.itemTypes' => 'required', + 'types.traversableClasses' => $o['traversableTypeHints'] ?? null, + ], fn($v) => $v !== null)), + 'SlevomatCodingStandard.TypeHints.ReturnTypeHintSpacing' => [ + 'spacing.typeDeclaration' => 'compact', + ], + 'SlevomatCodingStandard.TypeHints.UnionTypeHintFormat' => $typeFormat('SlevomatCodingStandard.TypeHints.UnionTypeHintFormat'), + 'SlevomatCodingStandard.TypeHints.UselessConstantTypeHint' => ['phpdoc.annotation.constantVar' => 'forbidden'], + 'SlevomatCodingStandard.Variables.DuplicateAssignmentToVariable' => ['mistakes.repeatedAssignment' => 'forbidden'], + 'Squiz.Arrays.ArrayBracketSpacing' => ['spacing.padding.offset' => 'forbidden'], + 'Squiz.Classes.SelfMemberReference' => ['qualification.currentClass' => 'self'], + 'Squiz.Classes.ValidClassName' => fn(array $o, Translation $t) => $t->set('naming.class', 'PascalCase'), + 'Squiz.Commenting.DocCommentAlignment' => ['phpdoc.stars' => 'aligned'], + 'Squiz.Commenting.FunctionComment.DuplicateReturn' => ['phpdoc.annotation.duplicateReturn' => 'forbidden'], + 'Squiz.Commenting.FunctionComment.ExtraParamComment' => ['phpdoc.annotation.extraParam' => 'forbidden'], + 'Squiz.Commenting.VariableComment' => fn(array $o, Translation $t) => $t->setAll([ + 'phpdoc.property.plainComment' => 'forbidden', + 'phpdoc.annotation.duplicateVar' => 'forbidden', + 'phpdoc.annotation.empty' => 'forbidden', + ]), + 'Squiz.ControlStructures.ControlSignature' => fn(array $o, Translation $t) => $t->setAll([ + 'braces.position.controlStructure' => 'sameLine', + 'braces.position.continuingKeyword' => 'sameLine', + 'spacing.keyword' => 'required', + 'spacing.openBrace' => 'required', + ]), + 'Squiz.ControlStructures.ForEachLoopDeclaration' => function (array $o, Translation $t) { + if (($o['requiredSpacesAfterOpen'] ?? 0) > 0 || ($o['requiredSpacesBeforeClose'] ?? 0) > 0) { + $t->warn('`Squiz.ControlStructures.ForEachLoopDeclaration` with spaces inside the parentheses has no equivalent; DressCode writes none.'); + } + $t->warn('`Squiz.ControlStructures.ForEachLoopDeclaration` also puts a single space around the `=>` of a `foreach`, which DressCode decides for every binary operator together, by `spacing.binaryOperator`.'); + $t->setAll([ + ...Translation::NoSpaceInParentheses, + 'spacing.keyword' => 'required', + 'casing.keyword' => 'lowercase', + ]); + }, + 'Squiz.ControlStructures.ForLoopDeclaration' => function (array $o, Translation $t) { + if (($o['requiredSpacesAfterOpen'] ?? 0) > 0 || ($o['requiredSpacesBeforeClose'] ?? 0) > 0) { + $t->warn('`Squiz.ControlStructures.ForLoopDeclaration` with spaces inside the parentheses has no equivalent; DressCode writes none.'); + } + $t->setAll([ + ...Translation::NoSpaceInParentheses, + 'spacing.semicolon.before' => 'compact', + 'spacing.semicolon.after' => 'spaced', + ]); + }, + 'Squiz.ControlStructures.LowercaseDeclaration' => ['casing.keyword' => 'lowercase'], + 'Squiz.Functions.FunctionDeclaration' => ['spacing.keyword' => 'required', 'spacing.call' => 'compact'], + 'Squiz.Functions.FunctionDeclarationArgumentSpacing' => ['spacing.comma' => 'spaced', 'spacing.alignment.comma' => 'tabs'], + 'Squiz.Functions.LowercaseFunctionKeywords' => ['casing.keyword' => 'lowercase'], + 'Squiz.Functions.MultiLineFunctionDeclaration' => [ + 'braces.position.function' => 'nextLine', + 'braces.position.multilineSignature' => 'sameLine', + 'braces.position.closure' => 'sameLine', + 'spacing.anonymousFunction' => 'spaced', + 'multiline.items.parameter' => 'perLine', + 'multiline.edges.parameter' => 'apart', + ], + 'Squiz.NamingConventions.ValidFunctionName' => fn(array $o, Translation $t) => $t->setAll([ + 'naming.method' => 'camelCase', + 'naming.function' => 'camelCase', + ]), + 'Squiz.NamingConventions.ValidVariableName' => function (array $o, Translation $t) { + $t->warn('`Squiz.NamingConventions.ValidVariableName` also rules on the underscore prefix of private properties, which DressCode does not.'); + $t->setAll(['naming.property' => 'camelCase', 'naming.variable' => 'camelCase']); + }, + 'Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps' => ['naming.property' => 'camelCase'], + 'Squiz.NamingConventions.ValidVariableName.NotCamelCaps' => ['naming.variable' => 'camelCase'], + 'Squiz.NamingConventions.ValidVariableName.StringNotCamelCaps' => ['naming.variable' => 'camelCase'], + 'Squiz.Operators.ValidLogicalOperators' => ['expressions.wordLogicalOperator' => 'forbidden'], + // `print_r` and `var_dump` print what was debugged, `error_log` does not + 'Squiz.PHP.DiscouragedFunctions' => fn(array $o, Translation $t) => $t->setAll([ + 'mistakes.debugOutput' => 'forbidden', + 'upgrading.api.forbiddenFunctions' => ['error_log'], + ]), + 'Squiz.PHP.GlobalKeyword' => ['mistakes.globalStatement' => 'forbidden'], + 'Squiz.PHP.InnerFunctions' => ['functions.innerFunction' => 'forbidden'], + 'Squiz.PHP.LowercasePHPFunctions' => ['casing.function' => 'declared'], + 'Squiz.Scope.MethodScope' => ['classes.visibility.constant' => 'required', + 'classes.visibility.property' => 'required', + 'classes.visibility.method' => 'required', 'classes.visibility.interfaceMethod' => 'required'], + 'Squiz.Scope.StaticThisUsage' => ['mistakes.thisOutsideObject' => 'forbidden'], + 'Squiz.Strings.ConcatenationSpacing' => fn(array $o, Translation $t) => $t->set( + 'spacing.concatenation', + ($o['spacing'] ?? 0) > 0 ? 'spaced' : 'compact', + ), + 'Squiz.Strings.DoubleQuoteUsage' => fn(array $o, Translation $t) => $t->set('literals.quotes', 'single'), + 'Squiz.Strings.EchoedStrings' => ['expressions.parenthesesAfterConstruct' => 'forbidden'], + 'Squiz.WhiteSpace.CastSpacing' => ['expressions.cast' => 'canonical'], + 'Squiz.WhiteSpace.ControlStructureSpacing' => function (array $o, Translation $t) { + $t->setAll([ + 'spacing.padding.controlStructure' => 'forbidden', + 'blankLines.bodyStart.controlStructure' => 0, + 'blankLines.bodyEnd.controlStructure' => 0, + 'blankLines.afterStatement' => array_fill_keys(['do', 'for', 'foreach', 'if', 'switch', 'try', 'while'], [1, null]), + ]); + }, + 'Squiz.WhiteSpace.FunctionOpeningBraceSpace' => fn(array $o, Translation $t) => $t->setAll([ + 'blankLines.bodyStart.function' => 0, + 'blankLines.bodyStart.closure' => 0, + ]), + 'Squiz.WhiteSpace.FunctionSpacing' => function (array $o, Translation $t) { + $t->warn('`Squiz.WhiteSpace.FunctionSpacing` leaves the blank lines around classes alone, while `blankLines` sets them together with those around functions.'); + $t->setAll([ + 'blankLines.aroundDeclarations' => $o['spacing'] ?? 2, + 'blankLines.aroundMethods' => $o['spacing'] ?? 2, + 'blankLines.aroundInterfaceMethods' => $o['spacing'] ?? 2, + 'blankLines.bodyStart.class' => $o['spacingBeforeFirst'] ?? 2, + 'blankLines.bodyEnd.class' => $o['spacingAfterLast'] ?? 2, + ]); + }, + 'Squiz.WhiteSpace.LogicalOperatorSpacing' => [ + 'spacing.binaryOperator' => 'spaced', + 'spacing.alignment.binaryOperator' => 'spaces', + ], + 'Squiz.WhiteSpace.ObjectOperatorSpacing' => ['spacing.objectOperator' => 'compact'], + 'Squiz.WhiteSpace.OperatorSpacing' => ['spacing.binaryOperator' => 'spaced', 'spacing.alignment.binaryOperator' => 'spaces'], + 'Squiz.WhiteSpace.OperatorSpacing.Unary' => ['spacing.unaryOperator' => 'compact', 'spacing.logicalNot' => 'compact'], + 'Squiz.WhiteSpace.ScopeClosingBrace' => fn(array $o, Translation $t) => $t->warn('`Squiz.WhiteSpace.ScopeClosingBrace` puts a closing brace on a line of its own at the indentation of the line opening it, which DressCode does wherever `braces` places the opening brace and `indentation.unit` indents.'), + 'Squiz.WhiteSpace.ScopeKeywordSpacing' => ['spacing.doubleColon' => 'compact'], + 'Squiz.WhiteSpace.SemicolonSpacing' => ['spacing.semicolon.before' => 'compact', 'multiline.semicolon' => 'sameLine'], + 'Squiz.WhiteSpace.SuperfluousWhitespace' => ['file.trailingWhitespace' => 'forbidden'], + ]; + } + + + /** + * Rules of a phpcs.xml ruleset: every with the properties it sets and every inside it or + * of 0 as a rule switched off, and what the ruleset says that no rule carries. A value is typed by its text, true, false + * and digits, because XML carries every value as a string. + * @throws ConfigurationException + */ + public static function readConfig(string $file): ForeignRules + { + if (!is_file($file)) { + throw new ConfigurationException("File `$file` does not exist."); + } + + $internal = libxml_use_internal_errors(use_errors: true); + libxml_clear_errors(); + $xml = simplexml_load_file($file); + $error = libxml_get_errors()[0] ?? null; + libxml_clear_errors(); + libxml_use_internal_errors($internal); + if ($xml === false) { + throw new ConfigurationException("File `$file` is not a PHP_CodeSniffer ruleset" . ($error ? ': ' . trim($error->message) . " on line $error->line." : '.')); + } elseif ($xml->getName() !== 'ruleset') { + throw new ConfigurationException("File `$file` is not a PHP_CodeSniffer ruleset: its root is `<{$xml->getName()}>`, not ``."); + } + + $rules = $warnings = []; + foreach ($xml->rule as $rule) { + $ref = (string) $rule['ref']; + $properties = []; + foreach ($rule->properties->property ?? [] as $property) { + $properties[(string) $property['name']] = self::readValue($property); + } + + $rules[$ref] = (string) $rule->severity === '0' ? false : ($properties === [] ? true : $properties); + foreach ($rule->exclude ?? [] as $exclude) { + $rules[(string) $exclude['name']] = false; + } + + // the paths a sniff leaves out become an override, which a whole set has no decisions for + if (isset($rule->{'exclude-pattern'}, self::getSets()[$ref])) { + $warnings[] = "The paths excluded from `$ref` are not carried over; turn its rules off there with an override."; + } + } + + return new ForeignRules($rules, warnings: $warnings); + } + + + private static function readValue(\SimpleXMLElement $property): mixed + { + if ((string) $property['type'] === 'array') { + $items = []; + foreach ($property->element as $element) { + $key = (string) $element['key']; + $value = (string) $element['value']; + if ($key === '') { + $items[] = $value; + } else { + $items[$key] = $value; + } + } + + return $items === [] + ? array_map(trim(...), explode(',', (string) $property['value'])) + : $items; + } + + $value = (string) $property['value']; + return match (true) { + $value === 'true' => true, + $value === 'false' => false, + ctype_digit($value) => (int) $value, + default => $value, + }; + } + + + /** @return array standard => preset */ + public static function getSets(): array + { + return [ + 'PSR1' => 'dresscode/psr12', + 'PSR2' => 'dresscode/psr12', + 'PSR12' => 'dresscode/psr12', + ]; + } +} diff --git a/src/Interop/PhpCsFixer.php b/src/Interop/PhpCsFixer.php new file mode 100644 index 0000000000..d473c13555 --- /dev/null +++ b/src/Interop/PhpCsFixer.php @@ -0,0 +1,1028 @@ +|\Closure(array, Translation): mixed> fixer => its decisions, or what to + * enable for its options; every option is read through `??` so that a fixer enabled with none translates too + */ + public static function getTranslations(): array + { + // the fixers indent by the indent of the configuration object, which the rules do not carry + $indentByConfig = function (array $o, Translation $t) { + $unit = match ($t->indent) { + "\t" => 'tab', + ' ' => '4 spaces', + ' ' => '2 spaces', + default => null, + }; + if ($unit === null) { + $t->warn('The fixers of the indentation indent by the `indent` ' . var_export($t->indent, return: true) . ' of the configuration, which has no equivalent; DressCode indents by a tab, four spaces or two.'); + } else { + $t->set('indentation.unit', $unit); + } + }; + + // the visibility of the elements the fixer names, `const`, `method` and `property` by default + $visibility = function (array $o, Translation $t) { + $elements = $o['elements'] ?? ['const', 'method', 'property']; + $t->setAll(array_filter([ + 'classes.visibility.constant' => in_array('const', $elements, true) ? 'required' : null, + 'classes.visibility.property' => in_array('property', $elements, true) ? 'required' : null, + 'classes.visibility.method' => in_array('method', $elements, true) ? 'required' : null, + 'classes.visibility.interfaceMethod' => in_array('method', $elements, true) ? 'required' : null, + 'classes.modifiers.order' => 'canonical', + ])); + }; + + // the options of the fixers of the types from the doc comments that reach beyond what `types.declaration` writes + $phpdocToType = fn(string $fixer, string $path) => function (array $o, Translation $t) use ($fixer, $path) { + foreach (['scalar_types' => 'a scalar type', 'union_types' => 'a union type'] as $option => $what) { + if (!($o[$option] ?? true)) { + $t->warn("`$fixer` with `$option=false` has no equivalent; DressCode also writes $what."); + } + } + $t->set($path, 'required'); + }; + + // `braces_position`, which the older versions name `curly_braces_position` + $bracesPosition = function (array $o, Translation $t) { + // a function goes below its signature unless the signature ends with a line break, which a multi-line one does + $t->setAll([ + 'braces.position.function' => ($o['functions_opening_brace'] ?? 'next_line_unless_newline_at_signature_end') === 'same_line' + ? 'sameLine' + : 'nextLine', + 'braces.position.multilineSignature' => 'sameLine', + 'braces.position.class' => ($o['classes_opening_brace'] ?? 'next_line') === 'same_line' ? 'sameLine' : 'nextLine', + 'braces.position.anonymousClass' => ($o['anonymous_classes_opening_brace'] ?? 'same_line') === 'same_line' + ? 'sameLine' + : 'nextLine', + 'braces.position.closure' => ($o['anonymous_functions_opening_brace'] ?? 'same_line') === 'same_line' + ? 'sameLine' + : 'nextLine', + 'braces.position.controlStructure' => ($o['control_structures_opening_brace'] ?? 'same_line') === 'same_line' + ? 'sameLine' + : 'nextLine', + 'braces.singlelineClosure' => ($o['allow_single_line_anonymous_functions'] ?? true) ? 'keep' : 'forbidden', + ]); + // single_line_empty_body, which runs after it, collapses an empty anonymous class all the same + $t->prefer( + 'braces.emptyBody.anonymousClass', + ($o['allow_single_line_empty_anonymous_classes'] ?? true) ? 'keep' : 'expanded', + ); + }; + + return [ + 'PhpCsFixerCustomFixers/declare_after_opening_tag' => fn(array $o, Translation $t) => $t->set( + 'file.strictTypes.position', 'sameLine', + ), + 'PhpCsFixerCustomFixers/comment_surrounded_by_spaces' => ['spacing.comment.marker' => 'spaced'], + 'PhpCsFixerCustomFixers/commented_out_function' => fn(array $o, Translation $t) => $t->setAll([ + 'mistakes.debugOutput' => 'forbidden', + 'mistakes.debugOutputFunctions' => array_values(array_unique($o['functions'] ?? ['print_r', 'var_dump', 'var_export'])), + ]), + 'PhpCsFixerCustomFixers/no_leading_slash_in_global_namespace' => fn(array $o, Translation $t) => $t->setAll([ + 'imports.leadingBackslash' => 'forbidden', + 'qualification.leadingBackslash' => 'forbidden', + ]), + 'PhpCsFixerCustomFixers/no_superfluous_concatenation' => [ + 'literals.concatenatedLiterals' => 'forbidden', + 'literals.emptyStringConcatenation' => 'forbidden', + ], + 'PhpCsFixerCustomFixers/no_useless_dirname_call' => ['hygiene.dirnameOfFile' => 'forbidden'], + 'PhpCsFixerCustomFixers/no_useless_strlen' => ['hygiene.strlenEmptyTest' => 'forbidden'], + 'PhpCsFixerCustomFixers/numeric_literal_separator' => [ + 'literals.digitSeparator.integer' => 'required', + 'literals.digitSeparator.fraction' => 'required', + ], + 'PhpCsFixerCustomFixers/phpdoc_array_style' => fn(array $o, Translation $t) => $t->set('phpdoc.types.array', 'generic'), + 'PhpCsFixerCustomFixers/phpdoc_type_list' => function (array $o, Translation $t) { + $t->warn('`phpdoc_type_list` turns `array` into `list`, a stronger type, which DressCode does not do.'); + $t->set('phpdoc.types.builtin', 'canonical')->set('phpdoc.types.array', 'generic'); + }, + 'align_multiline_comment' => function (array $o, Translation $t) { + if (($o['comment_type'] ?? 'phpdocs_only') !== 'phpdocs_only') { + $t->warn('`align_multiline_comment` with `comment_type` other than `phpdocs_only` has no equivalent; DressCode aligns the stars of doc comments only.'); + } + $t->set('phpdoc.stars', 'aligned'); + }, + 'array_indentation' => $indentByConfig, + 'array_syntax' => fn(array $o, Translation $t) => $t->set('literals.arraySyntax.array', $o['syntax'] ?? 'short'), + 'assign_null_coalescing_to_coalesce_equal' => ['expressions.assignment.targetInValue' => 'forbidden'], + 'attribute_block_no_spaces' => ['spacing.attribute' => 'compact'], + 'attribute_empty_parentheses' => fn(array $o, Translation $t) => $t->set( + 'classes.emptyParentheses.attribute', + ($o['use_parentheses'] ?? false) ? 'required' : 'forbidden', + ), + 'backtick_to_shell_exec' => ['upgrading.php.backticks' => 'forbidden'], + 'binary_operator_spaces' => function (array $o, Translation $t) { + $default = $o['default'] ?? 'single_space'; + if (str_starts_with((string) $default, 'align')) { + $t->warn('`binary_operator_spaces` aligns operators; DressCode only keeps an alignment that is already there.'); + } + $t->setAll([ + 'spacing.binaryOperator' => 'spaced', + 'spacing.alignment.binaryOperator' => $default === 'single_space' ? 'forbidden' : 'spaces', + ]); + }, + 'blank_line_after_namespace' => fn(array $o, Translation $t) => $t->setAll([ + 'blankLines.afterNamespace' => 1, + 'blankLines.beforeDeclaration' => 1, + ]), + 'blank_line_after_opening_tag' => fn(array $o, Translation $t) => $t->set('blankLines.afterOpeningTag', 1), + 'blank_line_before_statement' => function (array $o, Translation $t) { + $statements = $o['statements'] ?? ['break', 'continue', 'declare', 'return', 'throw', 'try']; + $supported = ['break', 'continue', 'do', 'for', 'foreach', 'if', 'return', 'switch', 'throw', 'try', 'while', 'yield']; + foreach (array_diff($statements, $supported) as $statement) { + $t->warn("`blank_line_before_statement` with `$statement` has no equivalent; DressCode knows no such statement kind."); + } + $t->set('blankLines.beforeStatement', array_fill_keys(array_intersect($statements, $supported), [1, null])); + }, + 'blank_line_between_import_groups' => fn(array $o, Translation $t) => $t->setAll([ + 'blankLines.betweenImports' => 0, + 'blankLines.betweenImportKinds' => 1, + ]), + 'blank_lines_before_namespace' => fn(array $o, Translation $t) => $t->set( + 'blankLines.beforeNamespace', + max(0, ($o['min_line_breaks'] ?? 2) - 1), + ), + 'braces_position' => $bracesPosition, + 'cast_spaces' => fn(array $o, Translation $t) => $t->set( + 'spacing.cast', + ($o['space'] ?? 'single') === 'single' ? 'spaced' : 'compact', + ), + 'class_definition' => fn(array $o, Translation $t) => $t->set( + 'spacing.anonymousClass', + ($o['space_before_parenthesis'] ?? false) ? 'spaced' : 'compact', + ), + 'class_keyword' => ['literals.classNameInString' => 'forbidden'], + 'class_attributes_separation' => function (array $o, Translation $t) { + // the blank lines before an element without and with a doc comment or an attribute + $counts = ['none' => [0, 0], 'one' => [1, 1], 'only_if_meta' => [0, 1]]; + $elements = $o['elements'] ?? [ + 'const' => 'one', + 'method' => 'one', + 'property' => 'one', + 'trait_import' => 'none', + 'case' => 'none', + ]; + if (isset($elements['method'])) { + [$min, $max] = $counts[$elements['method']] ?? [0, 1]; + $t->setBlankLines('blankLines.aroundMethods', $min, $max) + ->setBlankLines('blankLines.aroundInterfaceMethods', $min, $max); + } + + if (isset($elements['trait_import'])) { + [$min, $max] = $counts[$elements['trait_import']] ?? [0, 1]; + $t->setBlankLines('blankLines.betweenTraitUses', $min, $max); + } + + $members = array_intersect_key($elements, array_flip(['case', 'const', 'property'])); + ksort($members); + if ($members !== []) { + $plain = array_map(fn(string $value) => ($counts[$value] ?? [0, 1])[0], $members); + $documented = array_map(fn(string $value) => ($counts[$value] ?? [0, 1])[1], $members); + // only a separation the configuration chose is worth the warning, not the one of the defaults + if (count(array_unique($members)) > 1 && $members !== ['case' => 'none', 'const' => 'one', 'property' => 'one']) { + $t->warn('`class_attributes_separation` separates constants, properties and enum cases differently, while `blankLines` counts the lines between members of different kinds and before a documented one for them together; the translation takes the range of them all there.'); + } + + $places = ['case' => 'enumCase', 'const' => 'constant', 'property' => 'property']; + foreach ($plain as $element => $count) { + $t->setBlankLines("blankLines.betweenMembers.{$places[$element]}", $count, $count); + } + + $t->setBlankLines('blankLines.betweenMemberKinds', min($plain), max($plain)); + $t->setBlankLines('blankLines.beforeDocumentedMember', min($documented), max($documented)); + } + }, + 'class_reference_name_casing' => function (array $o, Translation $t) { + $t->set('casing.class', 'declared'); + $t->warn('`class_reference_name_casing` writes the classes PHP declares, while `casing.class` writes those of the project and its packages too where the configuration sets `typeAnalysis`.'); + }, + 'combine_consecutive_issets' => ['hygiene.separateIssets' => 'forbidden'], + 'combine_nested_dirname' => ['hygiene.dirnameOfFile' => 'forbidden'], + 'combine_consecutive_unsets' => ['hygiene.separateUnsets' => 'forbidden'], + 'compact_nullable_type_declaration' => ['spacing.typeDeclaration' => 'compact'], + 'concat_space' => fn(array $o, Translation $t) => $t->set( + 'spacing.concatenation', + ($o['spacing'] ?? 'none') === 'one' ? 'spaced' : 'compact', + ), + 'constant_case' => fn(array $o, Translation $t) => $t->set( + 'casing.trueFalseNull', + ($o['case'] ?? 'lower') === 'lower' ? 'lowercase' : 'uppercase', + ), + 'control_structure_braces' => ['braces.bracelessBody' => 'forbidden'], + 'curly_braces_position' => $bracesPosition, + 'control_structure_continuation_position' => fn(array $o, Translation $t) => $t->set( + 'braces.position.continuingKeyword', + ($o['position'] ?? 'same_line') === 'next_line' ? 'nextLine' : 'sameLine', + ), + 'declare_equal_normalize' => fn(array $o, Translation $t) => ($o['space'] ?? 'none') === 'none' + ? $t->set('spacing.declare', 'compact') + : $t->warn('`declare_equal_normalize` with `space=single` has no equivalent; DressCode writes `declare(strict_types=1)` without spaces.'), + 'declare_parentheses' => ['spacing.declare' => 'compact'], + 'declare_strict_types' => fn(array $o, Translation $t) => ($o['strategy'] ?? 'enforce') === 'remove' + ? $t->warn('`declare_strict_types` with `strategy=remove` has no equivalent; DressCode requires the declaration.') + : $t->setAll(['file.strictTypes.declaration' => 'required', 'file.strictTypes.position' => 'nextLine']), + 'dir_constant' => ['hygiene.dirnameOfFile' => 'forbidden'], + 'elseif' => ['controlFlow.elseif' => 'oneWord'], + 'encoding' => ['file.bom' => 'forbidden'], + 'escape_implicit_backslashes' => ['literals.implicitBackslash' => 'forbidden'], + // a tag of include and exclude is an annotation, written with or without its @, and since 3.x an attribute by the + // name the code writes it with, while DressCode names an attribute by its class + 'final_internal_class' => function (array $o, Translation $t) { + $t->set('classes.internalClass.final', 'required'); + $annotations = fn(array $tags) => array_map(fn(string $tag) => '@' . ltrim($tag, '@'), $tags); + $include = $o['include'] ?? $o['annotation_include'] ?? null; + if ($include === []) { + $t->warn('`final_internal_class` with an empty `include` makes every class final; `classes.internalClass.markers` cannot be empty, so it stays `@internal`.'); + } elseif ($include !== null) { + $t->set('classes.internalClass.markers', $annotations($include)); + } + + $exclude = $o['exclude'] ?? $o['annotation_exclude'] ?? null; + if ($exclude !== null) { + $t->set('classes.internalClass.except', $annotations($exclude)); + } + + $tags = [...$o['include'] ?? [], ...$o['exclude'] ?? []]; + if ($tags) { + $t->warn('`final_internal_class` reads `' . implode('`, `', $tags) . '` as attributes too, by the name the code writes them with; ' + . 'add an attribute to `classes.internalClass.markers` or `classes.internalClass.except` by its fully qualified class.'); + } + + if ($o['consider_absent_docblock_as_internal_class'] ?? false) { + $t->warn('`final_internal_class` makes a class without a doc comment final too, which DressCode does not.'); + } + }, + 'full_opening_tag' => ['file.openingTag' => 'required'], + 'fully_qualified_strict_types' => function (array $o, Translation $t) { + if (!($o['import_symbols'] ?? false)) { + $t->warn('`fully_qualified_strict_types` without `import_symbols` shortens a name only to an import the file has; DressCode adds the import it lacks.'); + } + + $t->setQualificationShape(['class' => 'imported', 'globalClass' => 'keep']); + // the tags whose names it shortens are the common ones unless the option names none + if (($o['phpdoc_tags'] ?? null) !== []) { + $t->set('qualification.class.phpdoc', 'imported'); + } + }, + 'function_declaration' => function (array $o, Translation $t) { + // the comma of parameters on one line is removed by every value but `keep`, a value another fixer gave staying + if ($o['trailing_comma_single_line'] ?? false) { + $t->warn('`function_declaration` with `trailing_comma_single_line=true` has no equivalent; DressCode removes the trailing comma of parameters on one line.'); + } else { + $t->prefer('multiline.trailingComma.parameter', 'optional'); + } + + $t->setAll([ + 'spacing.keyword' => 'required', + 'spacing.call' => 'compact', + ...Translation::NoSpaceInParentheses, + 'spacing.anonymousFunction' => ($o['closure_function_spacing'] ?? 'one') === 'one' ? 'spaced' : 'compact', + 'spacing.arrowFunction' => ($o['closure_fn_spacing'] ?? 'one') === 'one' ? 'spaced' : 'compact', + ]); + }, + 'general_phpdoc_annotation_remove' => function (array $o, Translation $t) { + if (!($o['case_sensitive'] ?? true)) { + $t->warn('`general_phpdoc_annotation_remove` with `case_sensitive=false` has no equivalent; DressCode removes an annotation by the letter case it is written in.'); + } + $annotations = array_map(fn(string $tag) => '@' . ltrim($tag, '@'), $o['annotations'] ?? []); + if ($annotations) { + $t->set('phpdoc.forbiddenAnnotations', $annotations); + } else { + $t->keep('phpdoc.forbiddenAnnotations'); + } + }, + 'get_class_to_class_keyword' => ['hygiene.classNameNotation.object' => 'required'], + 'global_namespace_import' => function (array $o, Translation $t) { + // true imports a name written with the backslash, false writes the backslash, null leaves the name alone, and + // a bare function or constant stands either way + $shape = fn(?bool $import) => match ($import) { + true => 'imported', + false => 'backslashed', + null => 'keep', + }; + $t->setQualificationShape([ + 'globalClass' => $shape(array_key_exists('import_classes', $o) ? $o['import_classes'] : true), + 'globalFunction' => $shape(array_key_exists('import_functions', $o) ? $o['import_functions'] : false), + 'globalConstant' => $shape(array_key_exists('import_constants', $o) ? $o['import_constants'] : false), + ]); + }, + 'group_import' => function (array $o, Translation $t) { + foreach ($o['group_types'] ?? ['classy', 'functions', 'constants'] as $type) { + $t->set('imports.grouping.' . match ($type) { + 'functions' => 'function', + 'constants' => 'constant', + default => 'class', + }, 'grouped'); + } + }, + 'heredoc_indentation' => fn(array $o, Translation $t) => $t->set( + 'indentation.heredoc', + ($o['indentation'] ?? 'start_plus_one') === 'start_plus_one' ? 1 : 0, + ), + 'heredoc_to_nowdoc' => ['literals.heredocWithoutInterpolation' => 'forbidden'], + 'include' => ['expressions.parenthesesAfterConstruct' => 'forbidden'], + 'indentation_type' => $indentByConfig, + 'increment_style' => fn(array $o, Translation $t) => $t->set( + 'expressions.assignment.increment', + ($o['style'] ?? 'pre') === 'pre' ? 'prefix' : 'postfix', + ), + 'integer_literal_case' => ['casing.hexDigit' => 'uppercase'], + 'is_null' => ['hygiene.is_null' => 'forbidden'], + 'linebreak_after_opening_tag' => fn(array $o, Translation $t) => $t->prefer('blankLines.afterOpeningTag', 0), + 'line_ending' => fn(array $o, Translation $t) => match ($t->lineEnding) { + "\n" => $t->set('file.lineEnding', 'LF'), + "\r\n" => $t->set('file.lineEnding', 'CRLF'), + default => $t->warn('`line_ending` writes the `lineEnding` ' . var_export($t->lineEnding, return: true) . ' of the configuration, which has no equivalent; DressCode ends a line with LF or CRLF.'), + }, + 'list_syntax' => fn(array $o, Translation $t) => $t->set('literals.arraySyntax.list', $o['syntax'] ?? 'short'), + 'logical_operators' => ['expressions.wordLogicalOperator' => 'forbidden'], + 'long_to_shorthand_operator' => ['expressions.assignment.targetInValue' => 'forbidden'], + 'lowercase_cast' => ['expressions.cast' => 'canonical'], + 'lowercase_keywords' => ['casing.keyword' => 'lowercase'], + 'lowercase_static_reference' => ['casing.keyword' => 'lowercase'], + 'magic_constant_casing' => ['casing.magicConstant' => 'uppercase'], + 'method_chaining_indentation' => ['indentation.chain' => 'flat'], + 'method_argument_space' => fn(array $o, Translation $t) => match ($o['on_multiline'] ?? 'ensure_fully_multiline') { + 'ensure_fully_multiline' => $t->setAll(['multiline.items.argument' => 'perLine', 'multiline.edges.argument' => 'apart']), + 'ignore' => $t, + default => $t->warn("`method_argument_space` with `on_multiline={$o['on_multiline']}` has no equivalent; DressCode never joins a multi-line call into one line."), + }, + 'modern_serialization_methods' => function (array $o, Translation $t) { + $t->warn('`modern_serialization_methods` renames `__sleep()` and `__wakeup()`; DressCode only reports them.'); + $t->set('upgrading.php.sleepAndWakeup', 'forbidden'); + }, + 'modernize_strpos' => function (array $o, Translation $t) { + if ($o['modernize_stripos'] ?? false) { + $t->warn('`modernize_strpos` with `modernize_stripos` rewrites `stripos()` too, which DressCode leaves alone, because PHP has no case-insensitive `str_contains()`.'); + } + + $t->set('upgrading.functions.substringFunctions', 'required'); + }, + 'modernize_types_casting' => ['hygiene.conversionFunction' => 'forbidden'], + 'modifier_keywords' => $visibility, + 'multiline_promoted_properties' => fn(array $o, Translation $t) => $t->set('multiline.spreadAlways', ['promotedProperty']), + 'multiline_whitespace_before_semicolons' => fn(array $o, Translation $t) => $t->set( + 'multiline.semicolon', + ($o['strategy'] ?? 'no_multi_line') === 'new_line_for_chained_calls' ? 'nextLine' : 'sameLine', + ), + 'native_function_casing' => function (array $o, Translation $t) { + $t->set('casing.function', 'declared'); + $t->warn('`native_function_casing` writes the functions PHP declares, while `casing.function` writes those of the project and its packages too where the configuration sets `typeAnalysis`.'); + }, + 'native_type_declaration_casing' => ['casing.type' => 'lowercase'], + 'new_expression_parentheses' => ['classes.instantiationWrapping' => 'forbidden'], + 'new_with_braces' => fn(array $o, Translation $t) => $t->setAll([ + 'classes.emptyParentheses.instantiation' => ($o['named_class'] ?? true) ? 'required' : 'forbidden', + 'classes.emptyParentheses.anonymousClass' => ($o['anonymous_class'] ?? true) ? 'required' : 'forbidden', + ]), + 'new_with_parentheses' => fn(array $o, Translation $t) => $t->setAll([ + 'classes.emptyParentheses.instantiation' => ($o['named_class'] ?? true) ? 'required' : 'forbidden', + 'classes.emptyParentheses.anonymousClass' => ($o['anonymous_class'] ?? true) ? 'required' : 'forbidden', + ]), + 'native_function_invocation' => function (array $o, Translation $t) { + if (($o['scope'] ?? 'all') === 'all') { + $t->warn('`native_function_invocation` with `scope=all` writes the backslash in the global namespace too, where DressCode writes none.'); + } + + $fallback = []; + $named = []; + foreach ($o['include'] ?? ['@compiler_optimized'] as $name) { + if ($name === '@compiler_optimized') { + $fallback['optimizedFunction'] = 'qualified'; + + } elseif ($name === '@internal' || $name === '@all') { + if ($name === '@internal') { + $t->warn('`native_function_invocation` with `@internal` writes the backslash before a global function of the project too; DressCode has no set of builtin functions.'); + } + + $fallback['function'] = 'qualified'; + + } else { + $named[] = $name; + } + } + + // strict takes the backslash from every function the include does not reach + if ($o['strict'] ?? true) { + $fallback['function'] ??= 'bare'; + } + + $named = [...$named, ...$o['exclude'] ?? []]; + if ($named !== []) { + $t->warn('`native_function_invocation` names functions one by one (`' . implode('`, `', $named) . '`), which DressCode decides by group; a function named follows `qualification.function.global`, or `qualification.function.optimized` where the compiler optimizes it.'); + } + + // the fixer qualifies with the backslash; a function neither the include nor strict reaches stays as it is + $shape = ['optimizedFunction' => 'backslashed']; + if (($fallback['function'] ?? null) === 'qualified') { + $shape['globalFunction'] = 'backslashed'; + } + + $t->setQualificationShape($shape); + $t->setQualificationFallback($fallback); + }, + 'native_constant_invocation' => function (array $o, Translation $t) { + if (($o['scope'] ?? 'all') === 'all') { + $t->warn('`native_constant_invocation` with `scope=all` writes the backslash in the global namespace too, where DressCode writes none.'); + } + + $fallback = []; + if ($o['fix_built_in'] ?? true) { + $t->warn('`native_constant_invocation` with `fix_built_in` writes the backslash before every constant of PHP; DressCode writes it before those the compiler computes with.'); + $fallback['optimizedConstant'] = 'qualified'; + } + + // strict takes the backslash from every constant the fixer does not escape + if ($o['strict'] ?? true) { + $fallback['constant'] = 'bare'; + } + + // true, false and null are left alone by the rule, so the default exclusion names nothing + $named = [...$o['include'] ?? [], ...array_diff($o['exclude'] ?? [], ['null', 'false', 'true'])]; + if ($named !== []) { + $t->warn('`native_constant_invocation` names constants one by one (`' . implode('`, `', $named) . '`), which DressCode decides by group; a constant named follows `qualification.constant.global`, or `qualification.constant.optimized` where the compiler computes with it.'); + } + + // the fixer qualifies with the backslash + $t->setQualificationShape(['optimizedConstant' => 'backslashed']); + $t->setQualificationFallback($fallback); + }, + 'no_alias_functions' => function (array $o, Translation $t) { + $sets = $o['sets'] ?? null; + foreach (array_intersect($sets ?? [], ['@mbreg', '@exif']) as $set) { + $t->warn("`no_alias_functions` with `$set` has no equivalent; PHP 8 has none of the aliases it replaces."); + } + + if (in_array('@snmp', $sets ?? [], true)) { + $t->warn('`no_alias_functions` with `@snmp` has no equivalent; DressCode leaves the aliases of the SNMP functions alone.'); + } + + $sets = array_map( + fn(string $set) => $set === '@internal' ? 'core' : strtolower(ltrim($set, '@')), + array_values(array_diff($sets ?? [], ['@mbreg', '@exif', '@snmp'])), + ); + $t->set('hygiene.aliasFunctions', ($o['sets'] ?? null) === null ? ['core', 'imap', 'pg'] : $sets); + }, + 'no_alternative_syntax' => ['braces.alternativeSyntax' => 'forbidden'], + 'no_blank_lines_after_class_opening' => ['blankLines.bodyStart.class' => 0, 'blankLines.bodyStart.anonymousClass' => 0], + 'no_blank_lines_after_phpdoc' => fn(array $o, Translation $t) => $t->set('blankLines.afterPhpdoc', 0), + 'no_break_comment' => fn(array $o, Translation $t) => $t->set( + 'controlFlow.switch.fallThroughComment', + $o['comment_text'] ?? 'no break', + ), + 'no_closing_tag' => ['file.closingTagAtEnd' => 'forbidden'], + 'no_empty_comment' => ['comments.empty' => 'forbidden'], + 'no_empty_phpdoc' => ['phpdoc.empty' => 'forbidden'], + 'no_empty_statement' => ['controlFlow.emptyStatement' => 'forbidden'], + 'no_extra_blank_lines' => function (array $o, Translation $t) { + $tokens = $o['tokens'] ?? ['extra']; + $jumps = array_values(array_intersect($tokens, ['break', 'continue', 'return', 'throw'])); + $unsupported = []; + foreach ($tokens as $token) { + match ($token) { + 'curly_brace_block' => $t->setAll([ + ...Translation::expandBlocks('blankLines.bodyStart', 0), + ...Translation::expandBlocks('blankLines.bodyEnd', 0), + ]), + 'break', 'continue', 'return', 'throw' => null, + 'attribute' => $t->set('blankLines.afterAttributes', 0), + 'use_trait' => $t->set('blankLines.betweenTraitUses', 0), + // imports of one kind lose the blank lines between them, those of different kinds keep what another + // fixer gives them + 'use' => $t->set('blankLines.betweenImports', 0)->prefer('blankLines.betweenImportKinds', 0), + default => $unsupported[] = $token, + }; + } + + if ($jumps) { + $t->warn('`no_extra_blank_lines` with `' . implode('`, `', $jumps) . '` removes the blank lines after the jump, which stand only before the closing brace or the next `case`, what follows a jump in its block being unreachable; set `blankLines.bodyEnd.controlStructure` and `blankLines.bodyEnd.switchCase` for those.'); + } + + if ($unsupported) { + $t->warn('`no_extra_blank_lines` with `' . implode('`, `', $unsupported) . '` has no equivalent; DressCode counts the blank lines place by place in `blankLines`, with no limit of their own for these.'); + } + }, + 'no_leading_import_slash' => fn(array $o, Translation $t) => $t->set('imports.leadingBackslash', 'forbidden'), + 'no_leading_namespace_whitespace' => $indentByConfig, + 'no_multiple_statements_per_line' => ['file.statementsPerLine' => 1], + 'no_null_property_initialization' => ['classes.untypedPropertyNullDefault' => 'forbidden'], + 'no_redundant_readonly_property' => ['classes.modifiers.implied' => 'forbidden'], + 'no_short_bool_cast' => ['expressions.doubleNegation' => 'forbidden'], + 'no_singleline_whitespace_before_semicolons' => ['spacing.semicolon.before' => 'compact'], + 'no_space_around_double_colon' => ['spacing.doubleColon' => 'compact'], + 'no_spaces_after_function_name' => ['spacing.call' => 'compact'], + 'no_spaces_around_offset' => ['spacing.padding.offset' => 'forbidden'], + 'no_spaces_inside_parenthesis' => Translation::NoSpaceInParentheses, + 'no_trailing_comma_in_singleline' => function (array $o, Translation $t) { + // a place another fixer asked to be required stays so, which removes the one-line comma as well + $places = [ + 'arguments' => 'multiline.trailingComma.argument', + 'array' => 'multiline.trailingComma.array', + 'array_destructuring' => 'multiline.trailingComma.list', + 'group_import' => 'multiline.trailingComma.import', + ]; + foreach ($o['elements'] ?? array_keys($places) as $element) { + if (isset($places[$element])) { + $t->prefer($places[$element], 'optional'); + } + } + }, + 'no_trailing_whitespace' => ['file.trailingWhitespace' => 'forbidden'], + 'no_trailing_whitespace_in_comment' => ['file.trailingWhitespace' => 'forbidden'], + 'no_trailing_whitespace_in_string' => ['mistakes.trailingWhitespaceInString' => 'forbidden'], + 'no_unneeded_braces' => function (array $o, Translation $t) { + if ($o['namespaces'] ?? false) { + $t->warn('`no_unneeded_braces` with `namespaces=true` also unwraps a braced namespace, which DressCode leaves alone.'); + } + $t->set('braces.bareStatementGroup', 'forbidden'); + }, + 'no_unneeded_control_parentheses' => ['expressions.parenthesesAfterConstruct' => 'forbidden'], + 'no_unneeded_final_method' => function (array $o, Translation $t) { + if (($o['private_methods'] ?? true) === false) { + $t->warn('`no_unneeded_final_method` with `private_methods=false` keeps `final` on a private method, which DressCode removes anyway.'); + } + + $t->set('classes.modifiers.implied', 'forbidden'); + }, + 'no_unneeded_curly_braces' => function (array $o, Translation $t) { + if ($o['namespaces'] ?? false) { + $t->warn('`no_unneeded_curly_braces` with `namespaces=true` also unwraps a braced namespace, which DressCode leaves alone.'); + } + $t->set('braces.bareStatementGroup', 'forbidden'); + }, + 'no_unneeded_import_alias' => ['imports.aliasEqualToName' => 'forbidden'], + 'no_unreachable_default_argument_value' => ['functions.unreachableParameterDefault' => 'forbidden'], + 'no_unused_imports' => ['imports.unused' => 'forbidden'], + 'no_superfluous_elseif' => fn(array $o, Translation $t) => $t->set('controlFlow.elseifAfterReturn', 'forbidden'), + 'no_superfluous_phpdoc_tags' => ['phpdoc.annotation.nativeTypeOnly' => 'forbidden'], + 'no_useless_concat_operator' => ['literals.concatenatedLiterals' => 'forbidden'], + 'no_useless_else' => ['controlFlow.elseAfterReturn' => 'forbidden'], + 'no_useless_return' => ['functions.trailingBareReturn' => 'forbidden'], + 'no_whitespace_before_comma_in_array' => fn(array $o, Translation $t) => $t->set('spacing.comma', 'spaced') + ->prefer('spacing.alignment.comma', 'optional'), + 'no_whitespace_in_blank_line' => ['file.trailingWhitespace' => 'forbidden'], + 'non_printable_character' => function (array $o, Translation $t) { + if (!($o['use_escape_sequences_in_strings'] ?? true)) { + $t->warn('`non_printable_character` without escape sequences has no equivalent; DressCode writes the character as an escape sequence to keep the value.'); + } + $t->set('mistakes.invisibleCharacter', 'forbidden'); + }, + 'not_operator_with_successor_space' => ['spacing.logicalNot' => 'spaced'], + 'not_operator_without_successor_space' => ['spacing.logicalNot' => 'compact'], + 'numeric_literal_separator' => function (array $o, Translation $t) { + $word = ($o['strategy'] ?? 'use_separator') === 'no_separator' ? 'forbidden' : 'required'; + $t->setAll(['literals.digitSeparator.integer' => $word, 'literals.digitSeparator.fraction' => $word]); + }, + 'nullable_type_declaration' => fn(array $o, Translation $t) => $t->set( + 'types.nullable', + ($o['syntax'] ?? 'question_mark') === 'union' ? 'union' : 'questionMark', + ), + 'nullable_type_declaration_for_default_null_value' => fn(array $o, Translation $t) => ($o['use_nullable_type_declaration'] ?? true) + ? $t->set('upgrading.php.implicitNullable', 'forbidden') + : $t->warn('`nullable_type_declaration_for_default_null_value` with `use_nullable_type_declaration=false` has no equivalent; DressCode adds the `?` instead of removing it.'), + 'object_operator_without_whitespace' => ['spacing.objectOperator' => 'compact'], + 'octal_notation' => ['upgrading.syntax.octalPrefix' => 'required'], + 'operator_linebreak' => function (array $o, Translation $t) { + $position = ($o['position'] ?? 'beginning') === 'end' ? 'lineEnd' : 'lineStart'; + $places = ($o['only_booleans'] ?? false) + ? ['logical', 'condition'] + : ['logical', 'condition', 'concatenation', 'arithmetic', 'bitwise', 'coalesce', 'pipe', 'comparison', 'ternary']; + $t->setAll(array_fill_keys(array_map(fn(string $place) => "multiline.operatorPosition.$place", $places), $position)); + }, + 'ordered_class_elements' => function (array $o, Translation $t) { + if (($o['sort_algorithm'] ?? 'none') !== 'none') { + $t->warn('`ordered_class_elements` sorts members by name; DressCode orders them by kind only.'); + } + $kinds = [ + 'use_trait' => 'traitUse', 'constant' => 'constant', 'constant_public' => 'publicConstant', + 'constant_protected' => 'protectedConstant', 'constant_private' => 'privateConstant', 'property' => 'property', + 'property_public' => 'publicProperty', 'property_protected' => 'protectedProperty', + 'property_private' => 'privateProperty', 'property_public_static' => 'publicStaticProperty', + 'property_protected_static' => 'protectedStaticProperty', 'property_private_static' => 'privateStaticProperty', + 'method' => 'method', 'method_public' => 'publicMethod', 'method_protected' => 'protectedMethod', + 'method_private' => 'privateMethod', 'method_public_static' => 'publicStaticMethod', + 'method_protected_static' => 'protectedStaticMethod', 'method_private_static' => 'privateStaticMethod', + 'construct' => 'constructor', 'destruct' => 'destructor', 'magic' => 'magicMethod', 'case' => 'enumCase', + // a static member of any visibility is a static one of each, in the place of the kind + 'property_static' => ['publicStaticProperty', 'protectedStaticProperty', 'privateStaticProperty'], + 'method_static' => ['publicStaticMethod', 'protectedStaticMethod', 'privateStaticMethod'], + ]; + $order = $unknown = $approximated = []; + foreach ($o['order'] ?? [] as $kind) { + if (isset($kinds[$kind])) { + array_push($order, ...(array) $kinds[$kind]); + } elseif (preg_match('~^(property|method)_(public|protected|private)_(abstract|readonly)~', $kind, $m)) { + $order[] = $m[2] . (str_ends_with($kind, '_static') ? 'Static' : '') . ucfirst($m[1]); + $approximated[] = $kind; + } else { + $unknown[] = $kind; + } + } + + $order = array_values(array_unique($order)); + if ($approximated) { + $t->warn('DressCode orders an abstract or a readonly member with the others of its visibility, so `' . implode('`, `', $approximated) . '` of `ordered_class_elements` stand for those.'); + } + + if ($unknown) { + $t->warn('The kinds `' . implode('`, `', $unknown) . '` of `ordered_class_elements` have no equivalent in `classes.members.order` and were left out.'); + } + + // a kind its three visibilities leave nothing to name is no kind of the order + foreach (['constant' => 'Constant', 'property' => 'Property', 'method' => 'Method'] as $kind => $word) { + if (!array_diff(["public$word", "protected$word", "private$word"], $order)) { + $order = array_values(array_diff($order, [$kind])); + } + } + + $t->setOrder('classes.members.order', $order ?: [ + 'traitUse', 'enumCase', 'publicConstant', 'protectedConstant', 'privateConstant', + 'publicProperty', 'protectedProperty', 'privateProperty', 'constructor', 'destructor', 'magicMethod', + 'publicMethod', 'protectedMethod', 'privateMethod', + ]); + }, + 'ordered_types' => function (array $o, Translation $t) { + if ($o['case_sensitive'] ?? false) { + $t->warn('`ordered_types` with `case_sensitive` sorts capitals first; `types.unionOrder` sorts regardless of case.'); + } + + $t->setAll([ + 'types.unionOrder' => ($o['sort_algorithm'] ?? 'alpha') === 'alpha' ? 'alphabetical' : 'keep', + 'types.nullPosition' => match ($o['null_adjustment'] ?? 'always_first') { + 'always_last' => 'last', + 'none' => 'keep', + default => 'first', + }, + ]); + }, + 'ordered_imports' => function (array $o, Translation $t) { + $sort = $o['sort_algorithm'] ?? 'alpha'; + if ($sort === 'length') { + $t->warn('`ordered_imports` with `sort_algorithm=length` has no equivalent; DressCode sorts alphabetically.'); + } + $kinds = $o['imports_order'] ?? null; + $t->setAll([ + 'imports.order.names' => $sort === 'none' ? 'keep' : 'alphabetical', + 'imports.order.kinds' => $kinds === null + ? 'keep' + : array_map(fn(string $kind) => $kind === 'const' ? 'constant' : $kind, $kinds), + 'imports.order.caseSensitive' => $o['case_sensitive'] ?? false, + ]); + }, + 'phpdoc_array_type' => fn(array $o, Translation $t) => $t->set('phpdoc.types.array', 'generic'), + 'phpdoc_indent' => ['phpdoc.stars' => 'aligned'], + 'phpdoc_line_span' => function (array $o, Translation $t) { + // a place given null is left as it is, one not given is spread + $spanOf = fn(string $place) => array_key_exists($place, $o) ? $o[$place] : 'multi'; + if ($spanOf('const') !== null || $spanOf('method') !== null) { + $t->warn('`phpdoc_line_span` of a constant or a method has no equivalent; DressCode leaves their doc comments as they are.'); + } + + $span = $spanOf('property'); + if ($span !== null) { + $t->set('phpdoc.property.lineSpan', $span === 'single' ? 'collapsed' : 'expanded'); + } + }, + 'phpdoc_list_type' => function (array $o, Translation $t) { + $t->warn('`phpdoc_list_type` turns `array` into `list`, a stronger type, which DressCode does not do.'); + $t->set('phpdoc.types.builtin', 'canonical')->set('phpdoc.types.array', 'generic'); + }, + 'phpdoc_no_access' => fn(array $o, Translation $t) => $t->set('phpdoc.forbiddenAnnotations', ['@access']), + 'phpdoc_no_duplicate_types' => ['phpdoc.types.duplicate' => 'forbidden'], + 'phpdoc_no_package' => fn(array $o, Translation $t) => $t->set('phpdoc.forbiddenAnnotations', ['@package', '@subpackage']), + 'phpdoc_no_useless_inheritdoc' => function (array $o, Translation $t) { + $t->warn('`phpdoc_no_useless_inheritdoc` removes `@inheritdoc` only from a class that inherits nothing; DressCode removes a doc comment of `@inheritDoc` alone everywhere.'); + $t->set('phpdoc.inheritdocOnly', 'forbidden'); + }, + 'phpdoc_readonly_class_comment_to_keyword' => ['upgrading.phpdoc.readonly' => 'required'], + 'phpdoc_scalar' => fn(array $o, Translation $t) => $t->set('phpdoc.types.builtin', 'canonical'), + 'phpdoc_tag_casing' => function (array $o, Translation $t) { + if (array_diff($o['tags'] ?? ['inheritDoc'], ['inheritDoc'])) { + $t->warn('`phpdoc_tag_casing` with `tags` other than `inheritDoc` has no equivalent; DressCode writes the annotations it knows in their canonical case.'); + } + $t->set('phpdoc.annotation.casing', 'canonical'); + }, + 'phpdoc_to_param_type' => $phpdocToType('phpdoc_to_param_type', 'types.declaration.parameter'), + 'phpdoc_to_property_type' => $phpdocToType('phpdoc_to_property_type', 'types.declaration.property'), + 'phpdoc_to_return_type' => $phpdocToType('phpdoc_to_return_type', 'types.declaration.return'), + 'phpdoc_trim' => ['phpdoc.blankLines' => 'trimmed'], + 'phpdoc_trim_consecutive_blank_line_separation' => ['phpdoc.blankLines' => 'trimmed'], + 'phpdoc_types' => fn(array $o, Translation $t) => $t->set('phpdoc.types.builtin', 'canonical'), + 'phpdoc_types_no_duplicates' => ['phpdoc.types.duplicate' => 'forbidden'], + 'phpdoc_types_order' => function (array $o, Translation $t) { + if ($o['case_sensitive'] ?? false) { + $t->warn('`phpdoc_types_order` with `case_sensitive=true` has no equivalent; DressCode sorts case-insensitively.'); + } + $t->setAll([ + 'phpdoc.types.unionOrder' => ($o['sort_algorithm'] ?? 'alpha') === 'alpha' ? 'alphabetical' : 'keep', + 'phpdoc.types.nullPosition' => match ($o['null_adjustment'] ?? 'always_first') { + 'always_last' => 'last', + 'none' => 'keep', + default => 'first', + }, + ]); + }, + 'protected_to_private' => ['classes.protectedInFinalClass' => 'forbidden', 'classes.protectedInEnum' => 'forbidden'], + 'regular_callable_call' => ['hygiene.call_user_func' => 'forbidden'], + 'return_type_declaration' => fn(array $o, Translation $t) => ($o['space_before'] ?? 'none') === 'none' + ? $t->set('spacing.typeDeclaration', 'compact') + : $t->warn('`return_type_declaration` with `space_before=one` has no equivalent; DressCode writes no space before the colon.'), + 'self_accessor' => ['qualification.currentClass' => 'self'], + 'self_static_accessor' => fn(array $o, Translation $t) => $t->set('qualification.staticInFinalClass', 'required'), + 'set_type_to_cast' => ['hygiene.settype' => 'forbidden'], + 'short_scalar_cast' => ['expressions.cast' => 'canonical'], + 'simple_to_complex_string_variable' => ['upgrading.php.dollarBraceInterpolation' => 'forbidden'], + 'simplified_if_return' => ['controlFlow.ifReturningTrueFalse' => 'forbidden'], + 'single_blank_line_at_eof' => ['file.finalLineEndings' => 1], + 'single_class_element_per_statement' => fn(array $o, Translation $t) => $t->setAll(array_fill_keys( + array_map(fn(string $e) => 'classes.members.grouping.' . match ($e) { + 'const' => 'constant', + 'trait' => 'traitUse', + default => $e, + }, $o['elements'] ?? ['const', 'property']), + 'separated', + )), + 'single_import_per_statement' => function (array $o, Translation $t) { + foreach (['class', 'function', 'constant'] as $kind) { + if ($o['group_to_single_imports'] ?? true) { + $t->set("imports.grouping.$kind", 'separated'); + } else { // a group use it lets stay gives way to a fixer making one + $t->prefer("imports.grouping.$kind", ['separated', 'grouped']); + } + } + }, + 'single_line_after_imports' => ['blankLines.afterImports' => 1, 'blankLines.beforeDeclaration' => 1], + 'single_line_comment_spacing' => ['spacing.comment.marker' => 'spaced'], + 'single_line_comment_style' => fn(array $o, Translation $t) => in_array( + 'hash', + $o['comment_types'] ?? ['asterisk', 'hash'], + true, + ) + ? $t->set('comments.singleline', 'slashes') + : $t->warn('`single_line_comment_style` without `hash` has no equivalent; DressCode only rewrites the hash comment.'), + 'single_line_empty_body' => [ + 'braces.emptyBody.class' => 'collapsed', + 'braces.emptyBody.anonymousClass' => 'collapsed', + 'braces.emptyBody.function' => 'collapsed', + 'braces.emptyBody.closure' => 'collapsed', + ], + 'single_quote' => fn(array $o, Translation $t) => $t->set('literals.quotes', 'single'), + 'single_space_around_construct' => function (array $o, Translation $t) { + // the space of a single construct is that of every keyword, which DressCode decides at once + if ( + ($o['constructs_followed_by_a_single_space'] ?? null) !== [] + || ($o['constructs_preceded_by_a_single_space'] ?? null) !== [] + ) { + $t->set('spacing.keyword', 'required'); + } + }, + 'single_trait_insert_per_statement' => ['classes.members.grouping.traitUse' => 'separated'], + 'space_after_semicolon' => function (array $o, Translation $t) { + if ($o['remove_in_empty_for_expressions'] ?? false) { + $t->warn('`space_after_semicolon` with `remove_in_empty_for_expressions=true` removes the space in an empty expression of a `for`, which DressCode does not tell apart.'); + } + $t->set('spacing.semicolon.after', 'spaced'); + }, + 'spaces_inside_parentheses' => fn(array $o, Translation $t) => ($o['space'] ?? 'none') === 'none' + ? $t->setAll(Translation::NoSpaceInParentheses) + : $t->warn('`spaces_inside_parentheses` with `space=single` has no equivalent; DressCode writes no space inside parentheses.'), + 'standardize_increment' => ['expressions.assignment.addingOne' => 'forbidden'], + 'standardize_not_equals' => ['expressions.comparison.notEquals' => 'exclamation'], + 'statement_indentation' => $indentByConfig, + 'static_lambda' => ['functions.staticClosure' => 'required'], + 'static_private_method' => ['classes.staticMethod' => 'required'], + 'strict_comparison' => ['expressions.comparison.loose' => 'forbidden'], + 'strict_param' => ['mistakes.strictArgument' => 'required'], + 'string_implicit_backslashes' => function (array $o, Translation $t) { + $modes = [$o['double_quoted'] ?? 'escape', $o['heredoc'] ?? 'escape', $o['single_quoted'] ?? 'unescape']; + if (in_array('unescape', $modes, true)) { + $t->warn('`string_implicit_backslashes` unescapes some strings; DressCode always escapes the backslash.'); + } + $t->set('literals.implicitBackslash', 'forbidden'); + }, + 'string_length_to_empty' => ['hygiene.strlenEmptyTest' => 'forbidden'], + 'switch_case_semicolon_to_colon' => ['upgrading.php.caseSemicolon' => 'forbidden'], + 'switch_case_space' => ['spacing.switchCase' => 'compact'], + 'switch_continue_to_break' => function (array $o, Translation $t) { + $t->warn('`switch_continue_to_break` rewrites the `continue`; DressCode only reports it.'); + $t->set('mistakes.continueInSwitch', 'forbidden'); + }, + 'ternary_operator_spaces' => [ + 'spacing.ternary' => 'spaced', + 'spacing.alignment.ternary' => 'optional', + ], + 'ternary_to_elvis_operator' => ['expressions.ternary.short' => 'required'], + 'ternary_to_null_coalescing' => ['expressions.coalesce' => 'required'], + 'trailing_comma_in_multiline' => function (array $o, Translation $t) { + $elements = $o['elements'] ?? ['arrays']; + if (in_array('array_destructuring', $elements, true)) { + $t->warn(in_array('arrays', $elements, true) + ? '`trailing_comma_in_multiline` with `array_destructuring` is translated only for the short `[...]`, which `trailingComma` sets under the key `array`; a multi-line `list()` keeps its comma as written.' + : '`trailing_comma_in_multiline` with `array_destructuring` and without `arrays` has no equivalent; DressCode decides the comma of a short `[...]` together with that of an array, under the key `array` of `trailingComma`, and a multi-line `list()` keeps its comma as written.'); + } + $places = [ + 'arrays' => 'multiline.trailingComma.array', + 'arguments' => 'multiline.trailingComma.argument', + 'parameters' => 'multiline.trailingComma.parameter', + 'match' => 'multiline.trailingComma.matchArm', + ]; + $t->setAll(array_fill_keys(array_values(array_intersect_key($places, array_flip($elements))), 'required')); + }, + 'trim_array_spaces' => Translation::NoSpaceInArrays, + 'type_declaration_spaces' => ['spacing.typeDeclaration' => 'compact'], + 'types_spaces' => fn(array $o, Translation $t) => ($o['space'] ?? 'none') === 'none' + ? $t->set('spacing.typeDeclaration', 'compact') + : $t->warn('`types_spaces` with `space=single` has no equivalent; DressCode writes a type without whitespace around `|` and `&`.'), + 'unary_operator_spaces' => fn(array $o, Translation $t) => ($o['only_dec_inc'] ?? false) + ? $t->warn('`unary_operator_spaces` with `only_dec_inc=true` has no equivalent; DressCode decides the space after `-`, `+`, `~` and `@` with the one after `++` and `--`, so it leaves them all alone.') + // the space after `!` is not_operator_with_successor_space's where the configuration has it too + : $t->set('spacing.unaryOperator', 'compact')->prefer('spacing.logicalNot', 'compact'), + 'use_arrow_functions' => ['functions.arrowFunction.oneExpression' => 'required'], + 'visibility_required' => $visibility, + 'void_return' => function (array $o, Translation $t) { + $t->warn('`void_return` adds only `void`; `types.declaration.return` also takes the type of `@return`.'); + $t->set('types.declaration.return', 'required'); + if ($o['fix_lambda'] ?? true) { + $t->set('types.declaration.anonymousFunction', 'required'); + } + }, + 'whitespace_after_comma_in_array' => fn(array $o, Translation $t) => $t->setAll([ + 'spacing.comma' => 'spaced', + 'spacing.alignment.comma' => ($o['ensure_single_space'] ?? false) ? 'forbidden' : 'optional', + ]), + 'yoda_style' => function (array $o, Translation $t) { + $equal = array_key_exists('equal', $o) ? $o['equal'] : true; + $identical = array_key_exists('identical', $o) ? $o['identical'] : true; + if ($equal !== $identical || ($o['less_and_greater'] ?? null) !== null) { + $t->warn('`yoda_style` decides `==`, `===`, `<` and `>` one by one, which DressCode decides together for the equality comparisons alone.'); + } + + $t->set('expressions.comparison.yoda', match ($identical) { + true => 'required', + false => 'forbidden', + default => 'keep', + }); + }, + ]; + } + + + /** + * Rules of a .php-cs-fixer.php, which returns a configuration object, or of a file returning the rules + * themselves, with the indent and the line ending the fixers of whitespace write, those of PHP CS Fixer where the + * object does not say. The file is executed in a process of its own, after the autoload of its project, which has + * php-cs-fixer installed during a migration from it. + * @throws ConfigurationException + */ + public static function readConfig(string $file): ForeignRules + { + if (!is_file($file)) { + throw new ConfigurationException("File `$file` does not exist."); + } + + // the script is copied out, as a phar holding it is no file PHP runs a script of + $temp = sys_get_temp_dir() . '/dresscode-fixer-config-' . bin2hex(random_bytes(6)); + FileSystem::copy(__DIR__ . '/read-php-cs-fixer-config.php', "$temp.php"); + try { + [$exit, $output] = ProcessRunner::run( + [...WorkerPool::buildPhpCommand() ?? [PHP_BINARY], "$temp.php", $file, "$temp.json"], + dirname($file), + self::ConfigTimeout, + ); + $data = json_decode((string) @file_get_contents("$temp.json"), associative: true); // @ the process may have written nothing + } finally { + @unlink("$temp.php"); // @ the process may hold it still + @unlink("$temp.json"); // @ or have written nothing + } + + $error = is_array($data) + ? $data['error'] ?? null + : ($exit === null ? 'it did not end in time' : (trim($output) ?: "exit code $exit")); + return match (true) { + $error === 'none' => throw new ConfigurationException("File `$file` returns neither rules nor a PHP CS Fixer configuration."), + $error !== null => throw new ConfigurationException( + (($data['missingClass'] ?? false) + ? "File `$file` needs the classes of PHP CS Fixer, which the autoload of its project does not load: " + : "File `$file` cannot be read: ") + . rtrim((string) $error, '.') . '.', + ), + default => new ForeignRules((array) $data['rules'], indent: (string) $data['indent'], lineEnding: (string) $data['lineEnding']), + }; + } + + + /** @return array rule set => preset */ + public static function getSets(): array + { + return [ + '@PER' => 'dresscode/perCs', + '@PER-CS' => 'dresscode/perCs', + '@PER-CS1.0' => 'dresscode/perCs', + '@PER-CS2.0' => 'dresscode/perCs', + '@PER-CS3.0' => 'dresscode/perCs', + '@PSR1' => 'dresscode/psr12', + '@PSR2' => 'dresscode/psr12', + '@PSR12' => 'dresscode/psr12', + '@Symfony' => 'dresscode/symfony', + ]; + } + + + /** + * The sets that move the code to a version of PHP, by the rules and sets each turns on, as PHP CS Fixer 3.95 defines + * them without `clean_namespace`, `no_unset_cast` and `normalize_index_brace`, which fix what PHP 8.0 no longer + * parses; `@PHP84Migration` is the older name of `@PHP8x4Migration`. + * @return array>> + */ + public static function getMigrationSets(): array + { + $afterHeredoc = ['after_heredoc' => true]; + return [ + '@PHP5x4Migration' => ['array_syntax' => true], + '@PHP5x6Migration:risky' => ['pow_to_exponentiation' => true], + '@PHP7x0Migration' => ['@PHP5x4Migration' => true, 'ternary_to_null_coalescing' => true], + '@PHP7x0Migration:risky' => [ + '@PHP5x6Migration:risky' => true, + 'combine_nested_dirname' => true, + 'declare_strict_types' => true, + 'non_printable_character' => true, + 'random_api_migration' => ['replacements' => [ + 'mt_getrandmax' => 'getrandmax', + 'mt_rand' => 'random_int', + 'mt_srand' => 'srand', + 'rand' => 'random_int', + ]], + ], + '@PHP7x1Migration' => ['@PHP7x0Migration' => true, 'list_syntax' => true, 'modifier_keywords' => true], + '@PHP7x1Migration:risky' => ['@PHP7x0Migration:risky' => true, 'void_return' => true], + '@PHP7x3Migration' => [ + '@PHP7x1Migration' => true, + 'heredoc_indentation' => true, + 'method_argument_space' => $afterHeredoc, + 'no_whitespace_before_comma_in_array' => $afterHeredoc, + 'trailing_comma_in_multiline' => $afterHeredoc, + ], + '@PHP7x4Migration' => [ + '@PHP7x3Migration' => true, + 'assign_null_coalescing_to_coalesce_equal' => true, + 'short_scalar_cast' => true, + ], + '@PHP7x4Migration:risky' => [ + '@PHP7x1Migration:risky' => true, + 'implode_call' => true, + 'no_alias_functions' => true, + 'use_arrow_functions' => true, + ], + '@PHP8x0Migration' => ['@PHP7x4Migration' => true], + '@PHP8x0Migration:risky' => [ + '@PHP7x4Migration:risky' => true, + 'get_class_to_class_keyword' => true, + 'modernize_strpos' => true, + 'no_alias_functions' => ['sets' => ['@all']], + 'no_php4_constructor' => true, + 'no_unneeded_final_method' => true, + 'no_unreachable_default_argument_value' => true, + ], + '@PHP8x1Migration' => ['@PHP8x0Migration' => true, 'octal_notation' => true], + '@PHP8x1Migration:risky' => ['@PHP8x0Migration:risky' => true], + '@PHP8x2Migration' => ['@PHP8x1Migration' => true, 'simple_to_complex_string_variable' => true], + '@PHP8x2Migration:risky' => ['@PHP8x1Migration:risky' => true, 'phpdoc_readonly_class_comment_to_keyword' => true], + '@PHP8x3Migration' => ['@PHP8x2Migration' => true], + '@PHP8x3Migration:risky' => ['@PHP8x2Migration:risky' => true], + '@PHP8x4Migration' => [ + '@PHP8x3Migration' => true, + 'new_expression_parentheses' => true, + 'nullable_type_declaration_for_default_null_value' => true, + ], + '@PHP8x4Migration:risky' => ['@PHP8x3Migration:risky' => true], + '@PHP8x5Migration' => ['@PHP8x4Migration' => true, 'switch_case_semicolon_to_colon' => true], + '@PHP8x5Migration:risky' => ['@PHP8x4Migration:risky' => true, 'modern_serialization_methods' => true], + ]; + } +} diff --git a/src/Interop/PhpCsFixerSource.php b/src/Interop/PhpCsFixerSource.php new file mode 100644 index 0000000000..9e3e6f7cea --- /dev/null +++ b/src/Interop/PhpCsFixerSource.php @@ -0,0 +1,273 @@ +getFinder()` or a variable one of them was assigned to, and the arguments are read where + * they are literals, `__DIR__`, `__FILE__` and what `.` joins of them, lists among them. What the reading cannot be sure + * of is told in `$unread`: a call reading something else or of a method it does not know, a call under a condition, + * several finders, a variable of a finder assigned again, and a class named `Finder` of another vendor. + * @internal + */ +final readonly class PhpCsFixerSource +{ + /** the calls of a finder that narrow it, which a scope of DressCode writes */ + private const Narrowing = ['in', 'exclude', 'notPath', 'notName', 'append']; + + /** the calls of a finder that narrow it in a way no scope of DressCode writes */ + private const Unwritable = ['name', 'path', 'filter', 'depth', 'date', 'size', 'contains', 'notContains']; + + /** the calls of a finder that change nothing of what it finds */ + private const Harmless = ['files', 'sortByName', 'sortByType', 'ignoreUnreadableDirs', 'getIterator', 'hasResults', 'count']; + + /** the classes of a finder */ + private const FinderClasses = ['phpcsfixer\finder', 'symfony\component\finder\finder']; + + + public function __construct( + /** @var list the directories `in()` takes, absolute */ + public array $in = [], + /** @var list the directories `exclude()` leaves out, relative to each of them */ + public array $exclude = [], + /** @var list what `notPath()` leaves out: a regular expression, or a text a path holds */ + public array $notPath = [], + /** @var list the globs of the names `notName()` leaves out */ + public array $notName = [], + /** @var list the files `append()` adds, as written */ + public array $append = [], + /** @var list the sets the rules turn on, `@PSR12` */ + public array $sets = [], + /** @var list the calls whose arguments were not read, or that narrow the finder in a way no scope writes */ + public array $unread = [], + ) { + } + + + /** What the code of the configuration file says; nothing for code that does not parse. */ + public static function fromCode(string $code, string $file): self + { + try { + $tree = new Parser()->parse($code); + } catch (ParseException) { + return new self(unread: ['the configuration, which does not parse']); + } + + $resolver = new NameResolver($tree); + $found = array_fill_keys([...self::Narrowing, 'sets', 'unread'], []); + $finders = $arrays = []; + foreach ($tree->find(AssignmentNode::class) as $assignment) { + if ($assignment->target instanceof VariableNode && ($name = $assignment->target->plainName) !== null) { + if (isset($finders[$name])) { + $found['unread'][] = "\$$name, which a finder was assigned to and is assigned again"; + } elseif (self::isFinder($assignment->expression, $finders, $resolver)) { + $finders[$name] = true; + } elseif ($assignment->expression instanceof ArrayNode) { + $arrays[$name] = $assignment->expression; + } + } + } + + $created = 0; + foreach ([...$tree->find(StaticMethodCallNode::class), ...$tree->find(NewNode::class)] as $node) { + if ($node->class instanceof NameNode && $node->class->shortName === 'Finder') { + if (!self::namesFinder($node->class, $resolver)) { + $found['unread'][] = '`' . $resolver->resolveClass($node->class, $node) . '`, a class named Finder of another vendor'; + } elseif ($node instanceof NewNode || $node->plainName === 'create') { + $created++; + } + } + } + + if ($created > 1) { + $found['unread'][] = "$created finders, whose scopes are taken together"; + } + + foreach ($tree->find(MethodCallNode::class) as $call) { + $method = $call->plainName; + if ($method === 'setRules') { + $rules = $call->arguments->getPlainValues()[0] ?? null; + $rules = $rules instanceof VariableNode ? $arrays[(string) $rules->plainName] ?? null : $rules; + if ($rules instanceof ArrayNode) { + array_push($found['sets'], ...self::readSets($rules)); + } + + continue; + + } elseif ($method === null || !self::isFinder($call->object, $finders, $resolver)) { + continue; + } elseif (self::isConditional($call)) { + $found['unread'][] = "$method(" . self::stripParens((string) $call->arguments) . '), which a condition decides'; + continue; + } elseif (in_array($method, self::Unwritable, true) || !in_array($method, [...self::Narrowing, ...self::Harmless], true)) { + $found['unread'][] = "$method()"; + continue; + } elseif (!in_array($method, self::Narrowing, true)) { + continue; + } + + $values = self::readArguments($call->arguments, $file); + if ($values === null) { + $found['unread'][] = "$method(" . self::stripParens((string) $call->arguments) . ')'; + } else { + array_push($found[$method], ...$values); + } + } + + return new self( + $found['in'], + $found['exclude'], + $found['notPath'], + $found['notName'], + $found['append'], + $found['sets'], + $found['unread'], + ); + } + + + /** @param array $finders the variables a finder was assigned to */ + private static function isFinder(ExpressionNode $node, array $finders, NameResolver $resolver): bool + { + while (($node instanceof MethodCallNode && $node->plainName !== 'getFinder') || $node instanceof ParenthesizedNode) { + $node = $node instanceof ParenthesizedNode ? $node->expression : $node->object; + } + + return match (true) { + $node instanceof MethodCallNode => true, + $node instanceof StaticMethodCallNode => $node->plainName === 'create' && self::namesFinder($node->class, $resolver), + $node instanceof NewNode => self::namesFinder($node->class, $resolver), + $node instanceof VariableNode => isset($finders[(string) $node->plainName]), + default => false, + }; + } + + + private static function namesFinder(mixed $class, NameResolver $resolver): bool + { + return $class instanceof NameNode && in_array(strtolower($resolver->resolveClass($class, $class)), self::FinderClasses, true); + } + + + /** Whether a branch, a condition or an arm decides whether the call is made. */ + private static function isConditional(Node $node): bool + { + for ($node = $node->parent; $node !== null; $node = $node->parent) { + if ( + $node instanceof IfNode + || $node instanceof ElseifNode + || $node instanceof ElseNode + || $node instanceof SwitchNode + || $node instanceof TernaryNode + || $node instanceof MatchNode + || $node instanceof MatchArmNode + ) { + return true; + } + } + + return false; + } + + + /** + * The strings the arguments are, a list unpacked; null where one is no literal. + * @return ?list + */ + private static function readArguments(ArgumentListNode $arguments, string $file): ?array + { + $values = []; + foreach ($arguments->getPlainValues() ?? [null] as $argument) { + $value = $argument === null ? null : self::readValue($argument, $file); + if ($value === null) { + return null; + } + + array_push($values, ...(array) $value); + } + + return $values; + } + + + /** @return string|list|null */ + private static function readValue(ExpressionNode $node, string $file): string|array|null + { + if ($node instanceof StringNode) { + return $node->toValue(); + } elseif ($node instanceof MagicConstantNode) { + return match (strtoupper($node->token->text)) { + '__DIR__' => dirname($file), + '__FILE__' => $file, + default => null, + }; + } elseif ($node instanceof BinaryOpNode && $node->operator->text === '.') { + $left = self::readValue($node->left, $file); + $right = self::readValue($node->right, $file); + return is_string($left) && is_string($right) ? $left . $right : null; + } elseif ($node instanceof ArrayNode) { + $values = []; + foreach ($node->items as $item) { + $value = $item instanceof ArrayItemNode && $item->key === null && $item->value instanceof ExpressionNode + ? self::readValue($item->value, $file) + : null; + if (!is_string($value)) { + return null; + } + + $values[] = $value; + } + + return $values; + } + + return null; + } + + + /** + * The sets the array of rules turns on, a key of `@` with the value `true`. + * @return list + */ + private static function readSets(ArrayNode $rules): array + { + $sets = []; + foreach ($rules->items as $item) { + if ( + $item instanceof ArrayItemNode + && $item->key instanceof StringNode + && str_starts_with($item->key->toValue(), '@') + && $item->value instanceof BooleanNode + && strtolower($item->value->token->text) === 'true' + ) { + $sets[] = $item->key->toValue(); + } + } + + return $sets; + } + + + private static function stripParens(string $arguments): string + { + return trim(substr(trim($arguments), 1, -1)); + } +} diff --git a/src/Interop/Pint.php b/src/Interop/Pint.php new file mode 100644 index 0000000000..13281d9463 --- /dev/null +++ b/src/Interop/Pint.php @@ -0,0 +1,69 @@ + 'laravel', + 'psr12' => 'psr12', + 'per' => 'perCs', + 'symfony' => 'symfony', + 'empty' => null, + ]; + + /** what a configuration of Laravel Pint says that `migrate config` carries over, the rest of it said in the warnings */ + private const Keys = ['preset', 'rules', 'exclude', 'cache-file']; + + + /** The standard of the preset, `laravel` where the configuration names none; null for one no standard is. */ + public static function findStandard(mixed $preset): ?string + { + return is_string($preset ??= 'laravel') ? self::Presets[$preset] ?? null : null; + } + + + /** + * The rules of a `pint.json`, its preset as the standard. + * @throws ConfigurationException for a file that is no configuration, or names a preset Laravel Pint does not have + */ + public static function readConfig(string $file): ForeignRules + { + $config = ComposerJson::read($file) ?? throw new ConfigurationException("File `$file` is no configuration of Laravel Pint."); + $preset = $config['preset'] ?? 'laravel'; + if (!is_string($preset) || !array_key_exists($preset, self::Presets)) { + throw new ConfigurationException( + "File `$file` names the preset `" . (is_string($preset) ? $preset : json_encode($preset)) . '`, which Laravel Pint does not have; it has ' + . implode(', ', array_map(fn(string $name) => "`$name`", array_keys(self::Presets))) . '.', + ); + } + + $standard = self::Presets[$preset]; + return new ForeignRules( + (array) ($config['rules'] ?? []), + $standard === null ? [] : ["dresscode/$standard"], + array_map( + fn(string $key) => "`$key` of `" + . basename($file) + . '` is not carried over; what the configuration checks may differ from what Laravel Pint checked.', + array_values(array_diff(array_keys($config), self::Keys)), + ), + ); + } +} diff --git a/src/Interop/Translation.php b/src/Interop/Translation.php new file mode 100644 index 0000000000..ac9c62559c --- /dev/null +++ b/src/Interop/Translation.php @@ -0,0 +1,554 @@ + 'forbidden', + 'spacing.padding.parameter' => 'forbidden', + 'spacing.padding.closureUse' => 'forbidden', + 'spacing.padding.controlStructure' => 'forbidden', + 'spacing.padding.grouping' => 'forbidden', + ]; + + /** no whitespace inside the brackets of an array and of a destructuring */ + public const NoSpaceInArrays = ['spacing.padding.array' => 'forbidden', 'spacing.padding.list' => 'forbidden']; + + /** how firmly a foreign rule gives the value of a decision */ + private const Kept = 0; + private const Preferred = 1; + private const Set = 2; + + /** @var array path => the value of the decision */ + public private(set) array $decisions = []; + + /** @var array{shape: array, fallback: array, optimizedCalls: bool} */ + public private(set) array $qualification = ['shape' => [], 'fallback' => [], 'optimizedCalls' => false]; + + /** @var list */ + public private(set) array $presets = []; + + /** @var list */ + public private(set) array $warnings = []; + + public private(set) ?int $lineLength = null; + + /** @var list paths the foreign rule sets under options other than those given */ + public private(set) array $covered = []; + + /** @var array path => how firmly its value is given */ + private array $firmness = []; + + /** @var array paths the foreign rules give values that contradict each other */ + private array $contradicted = []; + + /** @var array paths whose lists are orders, never united */ + private array $ordered = []; + + /** @var array paths whose lists name what is allowed, narrowed to what every foreign rule allows */ + private array $allowances = []; + + /** the foreign rule being translated, which the values placed and the warnings given meanwhile come from */ + private ?string $rule = null; + + /** @var array path => the foreign rule that first gave it a value */ + private array $origins = []; + + /** @var array> foreign rule => the warnings given while it was translated */ + private array $ruleWarnings = []; + + + public function __construct( + /** the indent of the configuration object of PHP CS Fixer, which its fixers of the indentation write */ + public readonly string $indent = ' ', + /** the line ending of the configuration object of PHP CS Fixer, which its `line_ending` writes */ + public readonly string $lineEnding = "\n", + /** the foreign rules read, as the configuration names them */ + public readonly int $ruleCount = 0, + ) { + } + + + public function addPreset(string $name): static + { + if (!in_array($name, $this->presets, true)) { + $this->presets[] = $name; + } + + return $this; + } + + + /** The widest line; of two foreign rules naming different ones the narrower is kept, and said so. */ + public function setLineLength(int $length): static + { + if ($this->lineLength !== null && $this->lineLength !== $length) { + $this->warn(sprintf( + 'The foreign rules name the line lengths `%d` and `%d`; DressCode has one and keeps the narrower.', + min($this->lineLength, $length), + max($this->lineLength, $length), + )); + } + + $this->lineLength = min($this->lineLength ?? $length, $length); + return $this; + } + + + /** The value of a decision, merged with what another foreign rule gave it. */ + public function set(string $path, mixed $value): static + { + $this->place($path, $value, self::Set); + return $this; + } + + + /** The value of a decision unless another foreign rule sets it, which then wins in either order. */ + public function prefer(string $path, mixed $value): static + { + $this->place($path, $value, self::Preferred); + return $this; + } + + + /** + * The values of several decisions. + * @param array $values path => value + */ + public function setAll(array $values): static + { + foreach ($values as $path => $value) { + $this->set($path, $value); + } + + return $this; + } + + + /** The decisions the foreign rule sets under other options, which the rule covers whatever it sets under these. */ + public function cover(string ...$paths): static + { + $this->covered = array_values(array_unique([...$this->covered, ...$paths])); + return $this; + } + + + /** + * The value for every place of the kind `blocks` of the group, which a translation writes out place by place. + * @return array path => the value + */ + public static function expandBlocks(string $group, mixed $value): array + { + return array_fill_keys(array_map(fn(string $place) => "$group.$place", Catalogue::PlaceKinds['blocks']), $value); + } + + + /** + * The blank lines of a place, a count or the range from `$min` to `$max`; a count another foreign rule gave the place + * widens it to the range of both, and the translation says so. + */ + public function setBlankLines(string $path, int $min, int $max): static + { + return $this->set($path, $min === $max ? $min : [$min, $max]); + } + + + /** + * An order whose sequence carries meaning; another foreign rule setting a different one contradicts it instead of + * uniting with it. + * @param list $order + */ + public function setOrder(string $path, array $order): static + { + $this->ordered[$path] = true; + return $this->set($path, $order); + } + + + /** + * A list of what is allowed; another foreign rule allowing a different one narrows it to what both allow instead of + * uniting with it, and one setting a single value the list allows narrows it to that value. + * @param list $allowed + */ + public function setAllowed(string $path, array $allowed): static + { + $this->allowances[$path] = true; + return $this->set($path, $allowed); + } + + + /** The value of a decision as it is given, whatever the foreign rules gave it before. */ + public function replace(string $path, mixed $value): static + { + $this->decisions[$path] = $value; + $this->firmness[$path] = self::Set; + unset($this->contradicted[$path]); + return $this; + } + + + /** The decision left out of the translation, whatever the foreign rules gave it. */ + public function remove(string $path): static + { + unset($this->decisions[$path], $this->firmness[$path], $this->contradicted[$path]); + return $this; + } + + + /** + * The decisions a foreign rule turned off stands for, or those a foreign rule governs while allowing either form, `keep` + * unless another foreign rule sets them, which then wins in either order. + */ + public function keep(string ...$paths): static + { + foreach ($paths as $path) { + $this->place($path, 'keep', self::Kept); + } + + return $this; + } + + + /** A value merges with one given as firmly, replaces one given less firmly and gives way to one given more. */ + private function place(string $path, mixed $value, int $firmness): void + { + if ($this->rule !== null) { + $this->origins[$path] ??= $this->rule; + } + + $firmness = $value === 'keep' ? self::Kept : $firmness; + $current = $this->firmness[$path] ?? -1; + if ($firmness > $current) { + $this->firmness[$path] = $firmness; + unset($this->contradicted[$path]); + $this->decisions[$path] = $value; + + } elseif ($firmness === $current && !isset($this->contradicted[$path])) { + $merged = $this->merge($path, $this->decisions[$path] ?? null, $value); + if (isset($this->contradicted[$path])) { + unset($this->decisions[$path]); + } else { + $this->decisions[$path] = $merged; + } + } + } + + + /** + * The value two foreign rules give a decision together: a list the union unless it is an order, or what both allow + * where it names what is allowed, which a single value set inside it narrows to that value, a map key by key, a + * boolean true when either says true, counts of blank lines the range of both; values that contradict each other + * mark the decision, which leaves it out, and are said so. + */ + private function merge(string $path, mixed $old, mixed $value): mixed + { + $isCount = fn(mixed $v) => is_int($v) || (is_array($v) && array_is_list($v) && count($v) === 2 && is_int($v[0])); + if ($old === $value) { + return $old; + + } elseif (str_starts_with($path, 'blankLines.') && $isCount($old) && $isCount($value)) { + $this->warn("The foreign rules count the blank lines of `$path` differently; DressCode has one count there and takes the range of them all."); + [$oldMin, $oldMax] = is_int($old) ? [$old, $old] : $old; + [$min, $max] = is_int($value) ? [$value, $value] : $value; + $range = [min($min, $oldMin), $max === null || $oldMax === null ? null : max($max, $oldMax)]; + return $range[0] === $range[1] ? $range[0] : $range; + + } elseif (is_array($old) && is_array($value) && array_is_list($old) && array_is_list($value) && isset($this->allowances[$path])) { + return array_values(array_intersect($old, $value)); + + } elseif (isset($this->allowances[$path]) && is_string($value) && is_array($old) && in_array($value, $old, true)) { + // a single value one foreign rule sets inside what the other one allows narrows it to that value, in either order + return $value; + + } elseif (isset($this->allowances[$path]) && is_string($old) && is_array($value) && in_array($old, $value, true)) { + return $old; + + } elseif (is_array($old) && is_array($value) && array_is_list($old) && array_is_list($value) && !isset($this->ordered[$path])) { + return array_values(array_unique([...$old, ...$value], SORT_REGULAR)); + + } elseif ( + is_array($old) && is_array($value) && !isset($this->ordered[$path]) + && !array_all([...array_keys($old), ...array_keys($value)], fn($key) => is_string($key)) + ) { + return $this->mergeBareNames($path, $old, $value); + + } elseif (is_array($old) && is_array($value)) { + foreach ($value as $key => $item) { + $old[$key] = array_key_exists($key, $old) ? $this->merge($path, $old[$key], $item) : $item; + } + + return $old; + + } elseif (is_bool($old) && is_bool($value)) { + return $old || $value; + } + + $this->contradicted[$path] = true; + $this->warn("The foreign rules give `$path` values that contradict each other; DressCode leaves it out, for the configuration to set by hand."); + return $old; + } + + + /** + * Two maps holding names standing alone, `[print_r]`, merged by the name, a name alone giving way to an entry of it. + * @param array $old + * @param array $value + * @return array + */ + private function mergeBareNames(string $path, array $old, array $value): array + { + $entries = []; + foreach ([$old, $value] as $map) { + foreach ($map as $key => $item) { + if (is_int($key)) { + $entries[(string) $item] ??= null; + } else { + $entries[$key] = isset($entries[$key]) ? $this->merge($path, $entries[$key], $item) : $item; + } + } + } + + return Map::collapseBareNames($entries); + } + + + /** + * The form a qualified name is written in, `imported`, `backslashed` or `keep`, by the kind of name: `class`, + * `function` and `constant` of every name, `globalClass`, `globalFunction` and `globalConstant` of a global one, + * `optimizedFunction` and `optimizedConstant` of one the compiler works with. + * @param array $shapes + */ + public function setQualificationShape(array $shapes): static + { + $this->qualification['shape'] = $shapes + $this->qualification['shape']; + return $this; + } + + + /** + * Whether a global function or constant stands `qualified`, `bare` or as written (`keep`): `function` and `constant` + * of every one, `optimizedFunction` of a call the compiler optimizes and `optimizedConstant` of a constant it + * computes with. + * @param array $fallbacks + */ + public function setQualificationFallback(array $fallbacks): static + { + $this->qualification['fallback'] = $fallbacks + $this->qualification['fallback']; + return $this; + } + + + /** A call the compiler optimizes is written in the form the optimization takes, its arguments included. */ + public function markCallsOptimized(): static + { + $this->qualification['optimizedCalls'] = true; + return $this; + } + + + public function warn(string $message): static + { + if (!in_array($message, $this->warnings, true)) { + $this->warnings[] = $message; + } + + if ($this->rule !== null) { + $this->ruleWarnings[$this->rule][] = $message; + } + + return $this; + } + + + /** + * Translates the foreign rule, what it places and warns about remembered as coming from it. + * @param \Closure(): void $translate + */ + public function translateRule(string $rule, \Closure $translate): void + { + $this->rule = $rule; + try { + $translate(); + } finally { + $this->rule = null; + } + } + + + /** + * The configuration the translation gives, the presets and the decisions, to be written; the first decision a + * foreign rule translated only in part gives carries the warnings about it, which outlive the output in the file. + */ + public function toDraft(): Draft + { + $draft = new Draft; + $draft->setUse(array_map(PluginRegistry::abbreviate(...), $this->presets)); + $commented = []; + foreach ($this->collectDecisions() as $path => $value) { + $rule = $this->origins[$path] ?? null; + $comment = $rule === null || isset($commented[$rule]) ? '' : implode(' ', $this->ruleWarnings[$rule] ?? []); + if ($rule !== null && $comment !== '') { + $commented[$rule] = true; + } + + $draft->setDecision($path, $value, $comment); + } + + return $draft; + } + + + /** + * The paths of the decisions the translation sets, those of the qualification included. + * @return list + */ + public function getPaths(): array + { + return array_keys($this->collectDecisions()); + } + + + /** + * What every configuration needs and the translation without a preset leaves to the defaults: the indentation and + * the line ending. + * @return array path => what it is + */ + public function findUntranslated(): array + { + return $this->presets ? [] : array_diff_key( + ['indentation.unit' => 'the indentation', 'file.lineEnding' => 'the line ending'], + array_flip($this->getPaths()), + ); + } + + + /** + * The decisions, those the two axes of the qualification come to among them, what they leave as it is left to the presets. + * @return array path => value as the configuration writes it + */ + public function collectDecisions(): array + { + $decisions = $this->decisions; + if ($this->qualification !== ['shape' => [], 'fallback' => [], 'optimizedCalls' => false]) { + $defaults = self::translateQualification([], [], optimizedCalls: false); + foreach (self::translateQualification(...array_values($this->qualification)) as $path => $value) { + if ($defaults[$path] !== $value) { + $decisions[$path] = $value; + } + } + } + + if ($this->lineLength !== null) { + $decisions['file.lineLength'] = $this->lineLength; + } + + // a range is kept as its two ends and a shape as its word while the foreign rules meet, and both are written as + // the configuration writes them + static $catalogue; + $catalogue ??= new ConfigResolver(new PluginRegistry)->getCatalogue(); + foreach ($decisions as $path => $value) { + $domain = $catalogue->find($path)?->domain; + if (str_starts_with($path, 'blankLines.')) { + $decisions[$path] = self::writeRanges($value); + } elseif ($domain instanceof Shapes && is_string($value) && isset($domain->shapes[$value])) { + $decisions[$path] = $domain->getExample($value); + } elseif ($domain instanceof Shapes && is_array($value) && array_all($value, fn($word) => isset($domain->shapes[$word]))) { + $decisions[$path] = array_map($domain->getExample(...), $value); + } + } + + return $decisions; + } + + + /** The count as the configuration writes it, `0-1` or `1+` for a range, and so each count of a map. */ + private static function writeRanges(mixed $value): mixed + { + return match (true) { + is_array($value) && array_keys($value) === [0, 1] && is_int($value[0]) => $value[0] . ($value[1] === null ? '+' : "-$value[1]"), + is_array($value) => array_map(self::writeRanges(...), $value), + default => $value, + }; + } + + + /** + * The decisions of the global and the other names the two axes come to: the form of a qualified name, and whether + * a global function or constant stands qualified or bare, which the compiler may decide apart. + * @param array $shape + * @param array $fallback + * @return array path => value + */ + private static function translateQualification(array $shape, array $fallback, bool $optimizedCalls): array + { + $values = [ + 'qualification.class.otherNamespace' => $shape['class'] ?? 'keep', + 'qualification.class.global' => $shape['globalClass'] ?? $shape['class'] ?? 'keep', + 'qualification.function.otherNamespace' => $shape['function'] ?? 'keep', + 'qualification.constant.otherNamespace' => $shape['constant'] ?? 'keep', + ]; + // a name of the namespace of the file is shortened to the bare name, the import of it being no form there + foreach (['class', 'function', 'constant'] as $kind) { + $values["qualification.$kind.currentNamespace"] = match ($shape[$kind] ?? null) { + 'imported' => 'bare', + 'backslashed' => 'backslashed', + default => 'keep', + }; + } + + foreach (['Function', 'Constant'] as $kind) { + $form = $shape['global' . $kind] ?? $shape[lcfirst($kind)] ?? null; + $global = self::combine($fallback[lcfirst($kind)] ?? null, $form); + $optimized = match ($fallback['optimized' . $kind] ?? null) { + 'bare' => 'bare', + 'qualified' => self::combine('qualified', $shape['optimized' . $kind] ?? $form), + default => 'keep', + }; + // the arguments of an optimized call written positionally are an intent of their own, the call then qualified + if ($optimized === 'keep' && $global === 'keep' && $kind === 'Function' && $optimizedCalls) { + $optimized = 'imported'; + } + + $values['qualification.' . lcfirst($kind) . '.global'] = $global; + // the optimized ones the foreign rules do not tell apart follow every global one + $values['qualification.' . lcfirst($kind) . '.optimized'] = $optimized === 'keep' ? $global : $optimized; + } + + return $values; + } + + + /** + * The forms a global name stands in, from whether it stands qualified or bare and the form of a qualified one; a + * bare one takes the backslash away and leaves an import. + * @return string|list + */ + private static function combine(?string $fallback, ?string $shape): string|array + { + $qualified = $shape === 'keep' ? null : $shape; + return match ($fallback) { + 'bare' => $qualified === 'imported' ? ['bare', 'imported'] : 'bare', + 'qualified' => $qualified ?? ['imported', 'backslashed'], + default => $qualified === null ? 'keep' : [$qualified, 'bare'], + }; + } +} diff --git a/src/Interop/Translator.php b/src/Interop/Translator.php new file mode 100644 index 0000000000..ffd1cfcf4c --- /dev/null +++ b/src/Interop/Translator.php @@ -0,0 +1,332 @@ +|\Closure(array, Translation): mixed> foreign rule => the + * decisions it stands for, or a closure setting them from the foreign options + */ + private readonly array $translations; + + /** @var array foreign rule set => preset */ + private readonly array $sets; + + /** @var array> foreign rule => the decisions it stands for */ + private array $paths = []; + + /** @var ?array> decision => the foreign rules standing for it */ + private ?array $foreignNames = null; + + private ?Catalogue $catalogue = null; + + + /** + * Without tables, those of PHP CS Fixer and PHP_CodeSniffer. + * @param ?array|\Closure(array, Translation): mixed> $translations + * @param ?array $sets + */ + public function __construct(?array $translations = null, ?array $sets = null) + { + $this->translations = $translations ?? PhpCsFixer::getTranslations() + PhpCodeSniffer::getTranslations(); + $this->sets = $sets ?? PhpCsFixer::getSets() + PhpCodeSniffer::getSets(); + } + + + /** + * @param array> $rules foreign rule => whether it is on, or its options + * @param string $indent of the configuration object of PHP CS Fixer + * @param string $lineEnding of the configuration object of PHP CS Fixer + * @param list $presets the presets the rules stand on, which no rule names, as the preset of Laravel Pint + */ + public function translate(array $rules, string $indent = ' ', string $lineEnding = "\n", array $presets = []): Translation + { + $translation = new Translation($indent, $lineEnding, count($rules)); + foreach ($presets as $preset) { + $translation->addPreset($preset); + } + + $rules = self::expandMigrationSets($rules); + $excluded = []; + foreach ($rules as $name => $options) { + if ($options === false) { + $excluded = [...$excluded, ...$this->translateDisabled($name, $translation, $rules)]; + continue; + } + + $options = is_array($options) ? $options : []; + $target = $this->translations[$name] ?? null; + if (isset($this->sets[$name])) { + $translation->addPreset($this->sets[$name]); + } elseif ($target === null) { + $translation->warn($this->explainUncovered($name)); + } else { + $translation->translateRule((string) $name, function () use ($target, $options, $translation, $name): void { + if (is_array($target)) { + $translation->setAll($target); + if ($options !== []) { + $translation->warn("The options of `$name` were not translated; set them by hand, `dresscode explain " . array_key_first($target) . '` describes them.'); + } + } else { + $target($options, $translation); + } + }); + } + } + + foreach ($excluded as $path) { + $translation->replace($path, 'keep'); + } + + // the sets of PER Coding Style in PHP CS Fixer leave `(new Foo())->bar()` alone, which the preset rewrites from PHP 8.4 + if (in_array('dresscode/perCs', $translation->presets, true) && !isset($rules['new_expression_parentheses'])) { + $translation->prefer('classes.instantiationWrapping', 'keep'); + } + + $this->fillCommaPlaces( + $translation, + isset($rules['trailing_comma_in_multiline']) && $rules['trailing_comma_in_multiline'] !== false, + ); + $this->leaveOutTyped($translation); + return $translation; + } + + + /** + * The rules a set moving the code to a version of PHP turns on, in its place, a later rule replacing an earlier one + * as PHP CS Fixer resolves them; a set turned off turns its rules off. + * @param array> $rules + * @return array> + */ + private static function expandMigrationSets(array $rules): array + { + $sets = PhpCsFixer::getMigrationSets(); + $expanded = []; + foreach ($rules as $name => $options) { + $set = $sets[preg_replace('~^@PHP(\d)(\d)Migration~', '@PHP$1x$2Migration', $name)] ?? null; + if ($set === null) { + $expanded[$name] = $options; + } else { + $members = self::expandMigrationSets($set); + $expanded = [...$expanded, ...$options === false ? array_map(fn() => false, $members) : $members]; + } + } + + return $expanded; + } + + + /** + * Every place of `multiline.trailingComma` stands in the translation, starting from what a preset of the + * translation gives it, or `keep` where none decides it, not from the defaults of the rule. A + * `trailing_comma_in_multiline` of PHP CS Fixer replaces what its set says of the comma of a list spread over lines, + * so a place it does not name keeps of the preset only the removal on one line, and without it a list the set + * requires the comma of stays required whatever `no_trailing_comma_in_singleline` says. + */ + private function fillCommaPlaces(Translation $translation, bool $multilineReplaced): void + { + $places = ['array', 'argument', 'parameter', 'matchArm', 'closureUse', 'import', 'list']; + if (!array_any($places, fn(string $place) => isset($translation->decisions["multiline.trailingComma.$place"]))) { + return; + } + + $preset = $translation->presets === [] + ? [] + : new ConfigResolver(new PluginRegistry($this)) + ->resolve(new Config(use: $translation->presets), Config::DefaultPhpVersion) + ->decisions; + foreach ($places as $place) { + $path = "multiline.trailingComma.$place"; + $value = ($preset[$path] ?? null)?->value; + $given = $value === null || $value->isKept() ? 'keep' : $value->getWord(); + $strict = $given === 'required' || $given === 'forbidden'; + $translation->replace($path, match (true) { + !isset($translation->decisions[$path]) => $multilineReplaced && $strict ? 'optional' : $given, + $translation->decisions[$path] === 'optional' && !$multilineReplaced && $strict => $given, + default => $translation->decisions[$path], + }); + } + } + + + /** + * A decision of a rule that needs the types of the code is left out and said so: the foreign configuration does not + * tell whether the project has PHPStan for `typeAnalysis`, without which the configuration setting it is refused. + */ + private function leaveOutTyped(Translation $translation): void + { + foreach ($translation->decisions as $path => $value) { + $rules = $this->getCatalogue()->getRulesOf($path); + if ($value !== 'keep' && $rules !== [] && array_all($rules, fn(string $rule) => RuleInfo::fromRule($rule)->typesRequired)) { + $translation->remove($path) + ->warn("`$path` needs the types of the code, so it is left out; set it together with `typeAnalysis: phpstan` where the project has PHPStan."); + } + } + } + + + /** + * A foreign rule switched off makes the requirements it stands for `keep` where no other rule of its tool that the + * configuration may run stands for them; a requirement another one stands for too is left as it is, and so is one an + * exclusion of a single message of its sniff would narrow, and the translation says so. A sniff and a message of it + * standing for the same requirement count as one. Returned are the requirements of an excluded message that its + * sniff, turned on by name, sets as well, which the exclusion takes from it. + * @param array> $rules + * @return list + */ + private function translateDisabled(string $name, Translation $translation, array $rules): array + { + $paths = $this->findPaths($name); + $sniff = $name; + while ($paths === [] && substr_count($sniff, '.') > 2) { + $sniff = implode('.', array_slice(explode('.', $sniff), 0, -1)); + if ($narrowed = $this->findPaths($sniff)) { + $translation->warn("`$name` is excluded, but `" . implode('`, `', $narrowed) . '` cannot leave out that check alone, so ' + . (count($narrowed) > 1 ? 'they stay' : 'it stays') . ' as the rest of the configuration sets it.'); + return []; + } + } + + $sniff = implode('.', array_slice(explode('.', $name), 0, 3)); + $sniffOn = $sniff !== $name && ($rules[$sniff] ?? false) !== false; + $staying = $others = $excluded = []; + foreach ($paths as $path) { + if ($this->getCatalogue()->find($path)?->isRequirement() !== true) { + continue; + } + + $covering = array_filter( + $this->findForeignNames([$path]), + fn(string $other) => $other !== $name + && !str_starts_with($name, "$other.") + && !str_starts_with($other, "$name.") + && str_contains($other, '.') === str_contains($name, '.') + && $this->canRun($other, $rules), + ); + if ($covering) { + $staying[] = $path; + $others = [...$others, ...$covering]; + } elseif ($sniffOn) { + $excluded[] = $path; + } else { + $translation->keep($path); + } + } + + if ($staying) { + $list = implode('`, `', $staying); + $plural = count($staying) > 1; + $translation->warn("`$name` is turned off, but `$list` also " . ($plural ? 'stand' : 'stands') + . ' for `' . implode('`, `', array_unique($others)) . '`, so ' . ($plural ? 'they stay' : 'it stays') . '; set ' . ($plural ? 'them' : 'it') . ' to `keep` if none of them applies.'); + } + + return $excluded; + } + + + /** + * Whether the configuration may run a foreign rule: one it names is on unless turned off, and one it does not name + * may come from a set or a standard of its tool, whose members the translation does not know. + * @param array> $rules + */ + private function canRun(string $name, array $rules): bool + { + if (isset($rules[$name])) { + return $rules[$name] !== false; + } + + $fixer = !str_contains($name, '.'); + return array_any( + array_keys($rules), + fn(string $rule) => $rules[$rule] !== false + && ($fixer ? str_starts_with($rule, '@') : (bool) preg_match('~^[A-Z]\w*$~', $rule)), + ); + } + + + /** Why no rule stands for a foreign name: a rule set or a standard has no preset, a message of a sniff is not the sniff. */ + private function explainUncovered(string $name): string + { + if (str_starts_with($name, '@')) { + $start = str_starts_with($name, '@PhpCsFixer') ? 'the preset `symfony`, the nearest one' : 'the preset `perCs` or `psr12`'; + return "The rule set `$name` has no DressCode preset; start from $start."; + + } elseif (preg_match('~^[A-Z]\w*$~', $name)) { + return "The standard `$name` has no DressCode preset; start from the preset `perCs` or `psr12`."; + + } elseif (substr_count($name, '.') === 3 && $this->findPaths($sniff = substr($name, 0, (int) strrpos($name, '.')))) { + return "`$name` is one message of `$sniff`, which DressCode cannot enable alone."; + } + + return "No DressCode rule covers `$name`."; + } + + + /** + * The decisions a foreign name stands for; empty when none does. + * @return list + */ + public function findPaths(string $name): array + { + if (isset($this->paths[$name])) { + return $this->paths[$name]; + } + + $value = $this->translations[$name] ?? null; + if (is_array($value)) { + return $this->paths[$name] = array_keys($value); + } elseif ($value === null) { + return $this->paths[$name] = []; + } + + $value([], $translation = new Translation); + return $this->paths[$name] = array_values(array_unique([...$translation->getPaths(), ...$translation->covered])); + } + + + /** + * Names of other tools standing for any of the decisions, for the reference and the listing of the rules. + * @param list $paths + * @return list + */ + public function findForeignNames(array $paths): array + { + if ($this->foreignNames === null) { + $map = []; + foreach (array_keys($this->translations) as $name) { + foreach ($this->findPaths($name) as $path) { + $map[$path][] = $name; + } + } + + $this->foreignNames = $map; + } + + $names = array_flip(array_merge(...array_map(fn(string $path) => $this->foreignNames[$path] ?? [], $paths))); + return array_values(array_filter(array_keys($this->translations), fn(string $name) => isset($names[$name]))); + } + + + private function getCatalogue(): Catalogue + { + $core = (new CorePlugin)->getManifest(); + return $this->catalogue ??= new Catalogue(array_keys($core->rules), coreDecisions: $core->decisions); + } +} diff --git a/src/Interop/enums.php b/src/Interop/enums.php new file mode 100644 index 0000000000..0415652da1 --- /dev/null +++ b/src/Interop/enums.php @@ -0,0 +1,110 @@ + self::PhpCodeSniffer, + (bool) preg_match('~\.json$~Di', $file) => self::Pint, + default => self::PhpCsFixer, + }; + } + + + /** The formatter the word of a command runs, `vendor/bin/phpcs`; null for none. */ + public static function fromCommand(string $word): ?self + { + $name = basename(strtr($word, '\\', '/')); + foreach (['.phar', '.bat'] as $suffix) { + $name = str_ends_with($name, $suffix) ? substr($name, 0, -strlen($suffix)) : $name; + } + + return match ($name) { + 'phpcs', 'phpcbf' => self::PhpCodeSniffer, + 'php-cs-fixer' => self::PhpCsFixer, + 'pint' => self::Pint, + default => null, + }; + } + + + /** + * The files of its configuration a project keeps in its root, in the order the formatter looks for them, a local + * one before the one distributed. + * @return list + */ + public function getRootFiles(): array + { + return match ($this) { + self::PhpCodeSniffer => ['.phpcs.xml', 'phpcs.xml', '.phpcs.xml.dist', 'phpcs.xml.dist'], + self::PhpCsFixer => ['.php-cs-fixer.php', '.php-cs-fixer.dist.php'], + self::Pint => ['pint.json'], + }; + } + + + /** The option of its command naming the configuration. */ + public function getConfigOption(): string + { + return $this === self::PhpCodeSniffer ? '--standard' : '--config'; + } + + + /** + * The packages it is installed by. + * @return list + */ + public function getPackages(): array + { + return match ($this) { + self::PhpCodeSniffer => ['squizlabs/php_codesniffer'], + self::PhpCsFixer => ['friendsofphp/php-cs-fixer', 'php-cs-fixer/shim'], + self::Pint => ['laravel/pint'], + }; + } + + + /** Whether reading its configuration runs code, which is PHP of the project. */ + public function runsCode(): bool + { + return $this === self::PhpCsFixer; + } +} + + +/** + * How sure it is that the project runs a configuration of a formatter, the stronger the higher. + * @internal + */ +enum Evidence: int +{ + /** the file lies where the formatter finds it, nothing else says the project runs it */ + case File = 0; + + /** the project requires a package of the formatter */ + case Package = 1; + + /** a script of composer.json runs the formatter with it */ + case Script = 2; +} diff --git a/src/Interop/read-php-cs-fixer-config.php b/src/Interop/read-php-cs-fixer-config.php new file mode 100644 index 0000000000..4c5b5f8254 --- /dev/null +++ b/src/Interop/read-php-cs-fixer-config.php @@ -0,0 +1,37 @@ + ['rules' => $config, 'indent' => ' ', 'lineEnding' => "\n"], + is_object($config) && method_exists($config, 'getRules') => [ + 'rules' => $config->getRules(), + 'indent' => method_exists($config, 'getIndent') ? $config->getIndent() : ' ', + 'lineEnding' => method_exists($config, 'getLineEnding') ? $config->getLineEnding() : "\n", + ], + default => ['error' => 'none'], + }; +} catch (Throwable $e) { + $data = ['error' => $e->getMessage(), 'missingClass' => (bool) preg_match('~^Class ".+" not found$~', $e->getMessage())]; +} + +file_put_contents($result, json_encode($data, JSON_THROW_ON_ERROR)); diff --git a/src/Measuring/Answer.php b/src/Measuring/Answer.php new file mode 100644 index 0000000000..b10e63e3a9 --- /dev/null +++ b/src/Measuring/Answer.php @@ -0,0 +1,29 @@ + path => the value it writes, as the configuration writes it */ + public array $values = [], + /** what the values it writes rest on, written beside them; "your answer" where nothing more is to say */ + public string $comment = 'your answer', + /** @var ?list the standard it measures against instead */ + public ?array $measureAgainst = null, + ) { + } +} diff --git a/src/Measuring/Budget.php b/src/Measuring/Budget.php new file mode 100644 index 0000000000..e13f4e5993 --- /dev/null +++ b/src/Measuring/Budget.php @@ -0,0 +1,74 @@ +files = $given ?? max(self::MinFiles, min(self::MaxFiles, (int) ceil(self::Share * $scope))); + } + + + /** + * The decisions to leave unchecked until the files the fixes of the rest would change together fit, each time the + * one whose files the others change least, so that as few as can be are left, the costlier first among equals; a + * protected decision is never left, as the others stand on it, and the files it changes are fixed whatever the + * budget, so they do not count against it; neither does a decision the user answered. + * @param array> $files decision => the files a fix of it would change + * @param list $answered + * @return list + */ + public function findDropped(array $files, array $answered = []): array + { + $files = array_filter( + $files, + fn(string $decision) => !self::isFoundation($decision) && !in_array($decision, $answered, true), + ARRAY_FILTER_USE_KEY, + ); + $union = fn(array $files) => count(array_replace([], ...array_values($files))); + $dropped = []; + while ($files !== [] && $union($files) > $this->files) { + // what leaving each out spares: the files no other decision changes too, the files of its own among equals + $spared = array_map(fn(string $decision) => [ + $union($files) - $union(array_diff_key($files, [$decision => true])), + count($files[$decision]), + ], array_combine(array_keys($files), array_keys($files))); + uasort($spared, fn(array $a, array $b) => $b <=> $a); + $decision = (string) array_key_first($spared); + $dropped[] = $decision; + unset($files[$decision]); + } + + return $dropped; + } + + + /** Whether the decision is one the others stand on: the indentation, the line ending and the long lines. */ + public static function isFoundation(string $decision): bool + { + return in_array($decision, MeasurementPlan::FirstLayer, true) || str_starts_with($decision, 'indentation.'); + } +} diff --git a/src/Measuring/FileSample.php b/src/Measuring/FileSample.php new file mode 100644 index 0000000000..d0c52ed928 --- /dev/null +++ b/src/Measuring/FileSample.php @@ -0,0 +1,169 @@ + the sample, relative to the root */ + public array $files, + /** @var array directory the sample is shared among => the files picked of it, and of all */ + public array $directories = [], + ) { + } + + + /** + * The sample of the scope, all of a small one, within the limits given otherwise. + * @param list $files the scope, sorted, relative to the root + */ + public static function pick(string $root, array $files, ?int $maxFiles = null, ?int $maxBytes = null): self + { + $whole = count($files) <= self::WholeFiles; + $maxFiles ??= $whole ? self::WholeFiles : self::MaxFiles; + $maxBytes ??= $whole ? self::WholeBytes : self::MaxBytes; + $groups = self::groupByDirectory($files); + $quotas = self::share($maxFiles, array_map(count(...), $groups)); + $picked = $directories = []; + foreach ($groups as $directory => $group) { + if ($quotas[$directory] === 0) { + $directories[$directory] = [0, count($group)]; + continue; + } + + $step = max(1, (int) ceil(count($group) / $quotas[$directory])); + $budget = $maxBytes * $quotas[$directory] / array_sum($quotas); + $bytes = $count = 0; + foreach ($group as $index => $file) { + if ($index % $step !== 0) { + continue; + } + + $size = (int) @filesize("$root/$file"); // @ file may have been deleted meanwhile + if ($bytes + $size <= $budget) { + $picked[] = $file; + $bytes += $size; + $count++; + } + } + + $directories[$directory] = [$count, count($group)]; + } + + if ($picked === [] && $files !== []) { // one file over the limits is evidence, none is not + $sizes = array_map(fn(string $file) => (int) @filesize("$root/$file"), $files); // @ file may have been deleted meanwhile + $picked = [$files[(int) array_search(min($sizes), $sizes, strict: true)]]; + foreach ($groups as $directory => $group) { + $directories[$directory] = [(int) in_array($picked[0], $group, true), count($group)]; + } + } + + sort($picked, SORT_STRING); + return new self($picked, $directories); + } + + + /** + * The files by the directories the sample is shared among: those of the root, or of the one directory the scope lies + * in, down to the depth where they part. + * @param list $files + * @return array> + */ + private static function groupByDirectory(array $files): array + { + $groups = ['' => $files]; + for ($depth = 1; count($groups) === 1 && $depth <= self::MaxDepth; $depth++) { + $deeper = []; + foreach ($files as $file) { + $parts = explode('/', $file); + $deeper[implode('/', array_slice($parts, 0, min($depth, count($parts) - 1)))][] = $file; + } + + if (array_keys($deeper) === array_keys($groups)) { // the files lie in that directory, not below it + break; + } + + $groups = $deeper; + } + + return $groups; + } + + + /** + * The files each directory gives the sample: an even share of what is left, a smaller directory all it has, and + * where there are more directories than files to give, the smaller ones none. + * @param array $sizes + * @return array + */ + private static function share(int $total, array $sizes): array + { + asort($sizes); + $quotas = []; + $left = count($sizes); + foreach ($sizes as $directory => $size) { + $quotas[$directory] = min($size, intdiv($total, $left)); + $total -= $quotas[$directory]; + $left--; + } + + return $quotas; + } + + + /** The directory of the sample the file belongs to, the deepest that holds it; '' for one of the root. */ + public function findDirectory(string $file): string + { + $found = ''; + foreach (array_keys($this->directories) as $directory) { + if (str_starts_with($file, "$directory/") && strlen($directory) > strlen($found)) { + $found = (string) $directory; + } + } + + return $found; + } + + + /** Whether a comment in the head of the file says a generator wrote it. */ + public static function isGenerated(string $file): bool + { + $head = @file_get_contents($file, length: self::HeadLength); // @ file may have been deleted meanwhile + return $head !== false && array_any( + @\PhpToken::tokenize($head), // @ the lexer warns of the code, which is the check's to report + fn(\PhpToken $token) => $token->is([T_COMMENT, T_DOC_COMMENT]) && preg_match(self::GeneratedPattern, $token->text) === 1, + ); + } +} diff --git a/src/Measuring/Finding.php b/src/Measuring/Finding.php new file mode 100644 index 0000000000..e621c843be --- /dev/null +++ b/src/Measuring/Finding.php @@ -0,0 +1,35 @@ +file:$this->line $this->message"; + } +} diff --git a/src/Measuring/InProcessProbeRunner.php b/src/Measuring/InProcessProbeRunner.php new file mode 100644 index 0000000000..bd0a1dcd07 --- /dev/null +++ b/src/Measuring/InProcessProbeRunner.php @@ -0,0 +1,53 @@ + $config) { + FileSystem::write($file, Neon::encode($config, blockMode: true)); + [$loaded, $root] = Loader::load($file, $this->root, root: $this->root); + $factory = new RunnerFactory; + $runner = $factory->createRunner($factory->resolve($loaded, $root), cache: false, baseline: false); + $collected = new CollectingReporter; + $run = $runner->run($runner->files->findFiles($loaded->paths), fix: false, reporter: $collected); + $results[$key] = ProbeResult::fromRun($run, $collected->violations); + ($this->onProgress)?->__invoke(count($results), count($probes)); + } + } finally { + @unlink($file); // @ may not have been written + } + + return $results; + } +} diff --git a/src/Measuring/MeasuredDecision.php b/src/Measuring/MeasuredDecision.php new file mode 100644 index 0000000000..39f1854768 --- /dev/null +++ b/src/Measuring/MeasuredDecision.php @@ -0,0 +1,35 @@ + by the keys of `MeasurementPlan::buildProbes()` */ + public array $results, + ) { + } + + + /** + * The files a rule failed in under some of the probes. + * @return list + */ + public function listFailedFiles(): array + { + return array_values(array_unique(array_merge( + ...array_map(fn(ProbeResult $probe) => $probe->failed, array_values($this->results)), + ))); + } +} diff --git a/src/Measuring/Measurement.php b/src/Measuring/Measurement.php new file mode 100644 index 0000000000..f67fae6d81 --- /dev/null +++ b/src/Measuring/Measurement.php @@ -0,0 +1,187 @@ + value => places that agree with it */ + public array $agreeing, + /** places the decision appears in at all */ + public int $opportunities, + /** what a place is, in the plural: files, strings */ + public string $unit, + /** places among the opportunities that agree with none of the values */ + public int $neither = 0, + /** files the rule failed in under some value, left out of the places */ + public int $failed = 0, + ) { + } + + + /** + * A decision that is a property of the file, from the probes of its values: a file agrees with a value when its probe + * reports nothing in it, and a file no probe reports has none of the decision in it and does not count. Neither does + * a file a rule fails in under any of the values, which would otherwise agree with that one. + * @param array $probes value => the probe of it; PHP keeps a numeric value as an int key + */ + public static function fromFileProbes(array $probes, string $decision): self + { + $failed = self::collectFailed($probes); + $reported = array_map(fn(ProbeResult $probe) => array_diff_key($probe->places[$decision] ?? [], $failed), $probes); + $opportunities = count(array_replace([], ...array_values($reported))); + return new self( + array_map(fn(array $files) => $opportunities - count($files), $reported), + $opportunities, + 'files', + failed: count($failed), + ); + } + + + /** + * A decision every place makes anew, from the probes of its values and the probe allowing all of them, which counts + * the places in none; the places of a file a rule fails in under any of the probes are left out of all of them. + * @param array $probes value => the probe of it; PHP keeps a numeric value as an int key + */ + public static function fromPlaceProbes(array $probes, string $decision, string $unit, ?ProbeResult $any = null): self + { + $failed = self::collectFailed([...array_values($probes), ...($any === null ? [] : [$any])]); + $count = fn(ProbeResult $probe) => array_sum(array_diff_key($probe->places[$decision] ?? [], $failed)); + return self::fromReportedPlaces(array_map($count, $probes), $unit, $any === null ? 0 : $count($any), count($failed)); + } + + + /** + * @param array $probes + * @return array + */ + private static function collectFailed(array $probes): array + { + return array_fill_keys(array_merge(...array_map(fn(ProbeResult $probe) => $probe->failed, array_values($probes))), true); + } + + + /** + * The measurement of a decision every place makes anew, from what the rule of each of its k values reports: a place + * agreeing with one value is reported by the other k − 1, a place agreeing with none by all k, and the profile + * allowing every value reports the latter once, so the places are (reported − neither) / (k − 1). + * @param array $reported value => places its rule reports + * @param int $neither places the profile allowing every value reports + */ + public static function fromReportedPlaces(array $reported, string $unit, int $neither = 0, int $failed = 0): self + { + $opportunities = count($reported) < 2 ? 0 : (int) round((array_sum($reported) - $neither) / (count($reported) - 1)); + return new self( + array_map(fn(int $count) => max(0, $opportunities - $count), $reported), + $opportunities, + $unit, + $neither, + $failed, + ); + } + + + public function getShare(string $value): float + { + return $this->opportunities === 0 ? 0.0 : $this->agreeing[$value] / $this->opportunities; + } + + + /** The value the code agrees with in most places; null when the decision appears nowhere. */ + public function findCommonest(): ?string + { + if ($this->opportunities === 0) { + return null; + } + + $agreeing = $this->agreeing; + arsort($agreeing); + return (string) array_key_first($agreeing); + } + + + /** What the code says of the decision, the tolerance counting where the decision takes one. */ + public function classify(bool $tolerance): Classification + { + $commonest = $this->findCommonest(); + return match (true) { + $commonest === null => Classification::Absent, + $this->opportunities >= self::MinPlaces && $this->getShare($commonest) >= self::Threshold => Classification::Settled, + $this->opportunities < self::MinPlaces && $this->getShare($commonest) === 1.0 => Classification::SettledWeakly, + $tolerance && $this->opportunities >= self::MinPlaces && $this->findTolerated() !== null => Classification::Tolerated, + default => Classification::Mixed, + }; + } + + + /** Every value some place agrees with, and its share, the commonest first: "tab in 55%, 4 spaces in 45% of 290 files". */ + public function describe(): string + { + $parts = []; + foreach ($this->sortAgreeing() as $value) { + $parts[] = sprintf('%s in %d%%', $value, round(100 * $this->getShare((string) $value))); + } + + return match (true) { + $this->opportunities === 0 => "no $this->unit", + $parts === [] => 'none of the ' . $this->formatPlaces() . ' agrees with any value', + default => implode(', ', $parts) . ' of ' . $this->formatPlaces() . ($this->neither ? ", $this->neither in none of them" : ''), + } . ($this->failed ? sprintf(', %d failing %s left out', $this->failed, $this->failed === 1 ? 'file' : 'files') : ''); + } + + + /** The places with their unit, "1 file" or "17 conditions". */ + public function formatPlaces(): string + { + return $this->opportunities . ' ' . ($this->opportunities === 1 ? substr($this->unit, 0, -1) : $this->unit); + } + + + /** + * The values some place agrees with, the commonest first, when together they hold at least the threshold of + * the places: as a list, the tolerance of what the code already has. + * @return ?list + */ + public function findTolerated(): ?array + { + if ( + $this->opportunities === 0 + || ($this->opportunities - $this->neither) / $this->opportunities < self::Threshold + ) { + return null; + } + + return array_map(strval(...), $this->sortAgreeing()); + } + + + /** @return list */ + private function sortAgreeing(): array + { + $agreeing = array_filter($this->agreeing); + arsort($agreeing); + return array_keys($agreeing); + } +} diff --git a/src/Measuring/MeasurementPlan.php b/src/Measuring/MeasurementPlan.php new file mode 100644 index 0000000000..5031d7f9bf --- /dev/null +++ b/src/Measuring/MeasurementPlan.php @@ -0,0 +1,334 @@ + 'file.lineLength']; + + /** the steps of the width of a line */ + public const LineLengths = [80, 100, 120, 140, 160, 200, 'none']; + + /** the counts a count is measured under at most, beside the values of the standards */ + private const MaxCount = 2; + + /** a requirement of one value whose fix touches at most this share of the files is written, its fix being cheap */ + private const StragglerShare = 0.1; + + /** a width of a line passes where at most this share of the files has a longer line */ + private const LineLengthShare = 0.01; + + + private function __construct( + public Decision $decision, + public MeasuringMethod $method, + /** @var array name => the value as the configuration writes it */ + public array $candidates, + /** the value letting every candidate pass, where the domain takes one */ + public mixed $any, + ) { + } + + + /** @param list $standardValues what the standards give the decision, `keep` among them */ + public static function fromDecision(Decision $decision, array $standardValues = []): self + { + $candidates = self::findCandidates($decision, $standardValues); + $domain = $decision->domain; + $method = match (true) { + isset(self::MeasuredOnParameter[$decision->path]) => MeasuringMethod::Ladder, + $domain instanceof Domains\Words, $domain instanceof Domains\Shapes => count($candidates) === 1 + ? MeasuringMethod::OneSided + : MeasuringMethod::Values, + $domain instanceof Domains\Count => MeasuringMethod::Values, + default => MeasuringMethod::Policy, + }; + return new self( + $decision, + $method, + $candidates, + $method === MeasuringMethod::Values ? self::findAnyValue($decision, $candidates) : null, + ); + } + + + /** + * The values to measure the decision under: a word, the example of a shape, a count from 0 to 2 within the bounds of + * the domain and the counts the standards give, the steps of the ladder; none for a policy. + * @param list $standardValues + * @return array + */ + private static function findCandidates(Decision $decision, array $standardValues): array + { + $domain = $decision->domain; + if (isset(self::MeasuredOnParameter[$decision->path])) { + return array_combine(array_map(strval(...), self::LineLengths), self::LineLengths); + } + + return match (true) { + $domain instanceof Domains\Words => array_combine(array_keys($domain->words), array_keys($domain->words)), + $domain instanceof Domains\Shapes => array_map(fn(array $shape) => $shape[0], $domain->shapes), + $domain instanceof Domains\Count => self::findCounts($domain, $standardValues), + default => [], + }; + } + + + /** + * @param list $standardValues + * @return array + */ + private static function findCounts(Domains\Count $domain, array $standardValues): array + { + $counts = []; + $max = min($domain->max ?? self::MaxCount, self::MaxCount); + foreach ([...range($domain->min, max($domain->min, $max)), ...$standardValues] as $value) { + if (is_int($value) && $value >= $domain->min && ($domain->max === null || $value <= $domain->max)) { + $counts[$value] = $value; + } + } + + ksort($counts); + return $counts; + } + + + /** + * The list of the words or the shapes, the range of the counts; null where the domain takes no tolerance. + * @param array $candidates + */ + private static function findAnyValue(Decision $decision, array $candidates): mixed + { + $domain = $decision->domain; + return match (true) { + count($candidates) < 2 => null, + ($domain instanceof Domains\Words || $domain instanceof Domains\Shapes) && $domain->tolerance => array_values($candidates), + $domain instanceof Domains\Count && $domain->range => min($candidates) . '-' . max($candidates), + default => null, + }; + } + + + /** + * The configurations to probe, each with the decision under one of its values over the base, the value letting every + * candidate pass under `path=*`. + * @param array $base + * @return array> `path=name` => the configuration + */ + public function buildProbes(array $base): array + { + $probes = []; + foreach ($this->candidates as $name => $value) { + $probes["{$this->decision->path}=$name"] = $this->lay($base, $value); + } + + if ($this->any !== null) { + $probes["{$this->decision->path}=*"] = $this->lay($base, $this->any); + } + + return $probes; + } + + + /** + * The value of the decision, or for a requirement measured on a parameter the step of the parameter with the + * requirement. + * @param array $base + * @return array + */ + private function lay(array $base, mixed $value): array + { + $path = $this->decision->path; + $parameter = self::MeasuredOnParameter[$path] ?? null; + return $parameter === null + ? Helpers::placeValue($base, $path, $value) + : Helpers::placeValue(Helpers::placeValue($base, $path, 'forbidden'), $parameter, $value); + } + + + /** + * What the file writes for the decision from what its probes report; null where the code says what the standard + * does, or nothing at all. + * @param array $results by the keys of `buildProbes()` + * @param \Closure(string, mixed): bool $isStandard whether the value of the path is the one of the standard + */ + public function decide(array $results, int $sampled, \Closure $isStandard): ?ProposedValue + { + $path = $this->decision->path; + $probes = $this->selectProbes($results); + $proposed = match ($this->method) { + MeasuringMethod::Values => $this->decideValues($results), + MeasuringMethod::OneSided => $this->decideOneSided(array_values($probes)[0], $sampled), + MeasuringMethod::Ladder => $this->decideLadder($probes, $sampled), + MeasuringMethod::Policy => $this->decision->takesKeep() + ? new ProposedValue($path, 'keep', 'not checked: your code does not follow the list of the standard') + : null, + }; + return $proposed === null || $isStandard($proposed->path, $proposed->value) ? null : $proposed; + } + + + /** + * How the code writes a decision of several values, from what its probes report, counted by files where it is a + * property of the file and by places otherwise. + * @param array $results by the keys of `buildProbes()` + */ + public function measure(array $results): Measurement + { + $path = $this->decision->path; + return $this->isFileProperty() + ? Measurement::fromFileProbes($this->selectProbes($results), $path) + : Measurement::fromPlaceProbes($this->selectProbes($results), $path, 'places', $results["$path=*"] ?? null); + } + + + /** Whether the decision is a property of the whole file, the indentation and `file.*`, counted by files. */ + public function isFileProperty(): bool + { + return $this->decision->path === 'indentation.unit' || str_starts_with($this->decision->path, 'file.'); + } + + + /** + * The files the probe of a candidate reports in, which a fix under that value would rewrite; null for a value no + * probe ran under. + * @param array $results by the keys of `buildProbes()` + */ + public function countFiles(array $results, mixed $value): ?int + { + $path = $this->decision->path; + if (isset(self::MeasuredOnParameter[$path])) { + return null; + } + + $wanted = $this->decision->accept($value)->toData(); + $name = array_find_key($this->candidates, fn(mixed $candidate) => $this->decision->accept($candidate)->toData() === $wanted); + return $name === null ? null : ($results["$path=$name"] ?? null)?->countFiles($path); + } + + + /** + * The files of the sample each candidate is written in, for a decision that is a property of the file: those the probe + * of another candidate reports and its own does not, a file a rule failed in under any of them left out. + * @param array $results by the keys of `buildProbes()` + * @return array> name => the files + */ + public function groupFilesByValue(array $results): array + { + $probes = $this->selectProbes($results); + $failed = array_merge(...array_map(fn(ProbeResult $probe) => $probe->failed, array_values($probes))); + $reported = array_map(fn(ProbeResult $probe) => array_keys($probe->places[$this->decision->path] ?? []), $probes); + $files = array_diff(array_unique(array_merge(...array_values($reported))), $failed); + return array_map(fn(array $own) => array_values(array_diff($files, $own)), $reported); + } + + + /** + * The results of the probes of the candidates, by the name of the candidate. + * @param array $results + * @return array + */ + private function selectProbes(array $results): array + { + $probes = []; + foreach (array_keys($this->candidates) as $name) { + $probes[$name] = $results["{$this->decision->path}=$name"]; + } + + return $probes; + } + + + /** + * A decision of several values: the one the code holds to, the ones it has together where the decision takes them, + * else unchecked. + * @param array $results + */ + private function decideValues(array $results): ?ProposedValue + { + $path = $this->decision->path; + $measurement = $this->measure($results); + $tolerated = array_map(fn(string $name) => $this->candidates[$name], $measurement->findTolerated() ?? []); + return match ($measurement->classify($this->any !== null)) { + Classification::Settled, Classification::SettledWeakly => new ProposedValue( + $path, + $this->candidates[(string) $measurement->findCommonest()], + self::describeShare($measurement), + ), + Classification::Tolerated => new ProposedValue( + $path, + is_int($tolerated[0] ?? null) ? min($tolerated) . '-' . max($tolerated) : $tolerated, + 'your code, ' . $measurement->describe(), + ), + Classification::Mixed => new ProposedValue($path, 'keep', 'not checked: your code has ' . $measurement->describe()), + Classification::Absent => null, + }; + } + + + /** A decision of one value, whose rule reports only what disagrees: the standard where few files would change, else unchecked. */ + private function decideOneSided(ProbeResult $probe, int $sampled): ?ProposedValue + { + $files = $probe->countFiles($this->decision->path); + return $files <= self::StragglerShare * $sampled + ? null + : new ProposedValue($this->decision->path, 'keep', sprintf('not checked: a fix would change %d of %d files', $files, $sampled)); + } + + + /** + * The width of a line: the smallest step of the ladder few files exceed. + * @param array $probes + */ + private function decideLadder(array $probes, int $sampled): ?ProposedValue + { + $path = $this->decision->path; + foreach ($this->candidates as $name => $step) { + $files = $probes[$name]->countFiles($path); + if ($files <= self::LineLengthShare * $sampled || $step === 'none') { + $widest = self::LineLengths[count(self::LineLengths) - 2]; + return new ProposedValue(self::MeasuredOnParameter[$path], $step, match (true) { + $step === 'none' => sprintf( + 'your code: %d of %d files have a line over %d', + $probes[$widest]->countFiles($path), + $sampled, + $widest, + ), + $files > 0 => "your code, all but $files of $sampled files", + default => "your code, all $sampled files", + }); + } + } + + return null; + } + + + private static function describeShare(Measurement $measurement): string + { + $share = $measurement->getShare((string) $measurement->findCommonest()); + return $share === 1.0 + ? 'your code, ' . ($measurement->opportunities === 1 ? '' : 'all ') . $measurement->formatPlaces() + : sprintf('your code, %d%% of %s', round(100 * $share), $measurement->formatPlaces()); + } +} diff --git a/src/Measuring/NamespaceDeclarations.php b/src/Measuring/NamespaceDeclarations.php new file mode 100644 index 0000000000..5e6aa04fe7 --- /dev/null +++ b/src/Measuring/NamespaceDeclarations.php @@ -0,0 +1,81 @@ + fully qualified, sorted */ + public array $functions, + /** @var list fully qualified, sorted */ + public array $constants, + /** @var list the files that do not parse or cannot be read, so that what they declare is not known */ + public array $unparsed, + ) { + } + + + /** + * Every file naming a namespace or `define()` is parsed, since a declaration can stand anywhere a statement can: the + * files given and those the autoload of `composer.json` loads one by one, which declare for the whole project + * wherever they lie. + * @param list $files relative to the root + */ + public static function collect(string $root, array $files): self + { + $parser = new Parser; + $names = [SymbolKind::Function->name => [], SymbolKind::Constant->name => []]; + $unparsed = []; + foreach (array_unique([...$files, ...ComposerJson::detectAutoloadFiles("$root/composer.json", $root, ['files'])]) as $file) { + $code = @file_get_contents(Helpers::toAbsolutePath($file, $root)); // @ the failure is told by the result + if ($code === false) { + $unparsed[] = $file; + continue; + } elseif (stripos($code, 'namespace') === false && stripos($code, 'define') === false) { + continue; + } + + try { + $node = $parser->parse($code); + } catch (ParseException) { + $unparsed[] = $file; + continue; + } + + foreach (new NameResolver($node)->findNamespacedDeclarations($node) as $declaration) { + $key = ConfigResolver::toSymbolKey($declaration->kind, $declaration->name); + $names[$declaration->kind->name][$key] ??= $declaration->name; + } + } + + $sort = function (array $list): array { + $list = array_values($list); + sort($list, SORT_STRING | SORT_FLAG_CASE); + return $list; + }; + return new self($sort($names[SymbolKind::Function->name]), $sort($names[SymbolKind::Constant->name]), $unparsed); + } + + + public function isEmpty(): bool + { + return !$this->functions && !$this->constants; + } +} diff --git a/src/Measuring/ProbeResult.php b/src/Measuring/ProbeResult.php new file mode 100644 index 0000000000..a04a5c1629 --- /dev/null +++ b/src/Measuring/ProbeResult.php @@ -0,0 +1,161 @@ +> decision => file => the places the decision reports in it */ + public array $places, + /** @var array> decision => the files a fix of it without risk would change */ + public array $fixable, + /** @var array> decision => file => the places a fix leaves, having no fix or a risky one */ + public array $left, + /** the files a fix would change */ + public int $changedFiles, + /** @var list the files that do not parse */ + public array $syntaxErrors, + /** @var list the files a rule failed in */ + public array $failed, + /** @var array> decision => the first places it reports */ + public array $findings = [], + ) { + } + + + /** @param array> $found the path => what the file violates */ + public static function fromRun(RunResult $run, array $found): self + { + $files = []; + foreach ($run->files as $result) { + $violations = []; + foreach ($found[$result->path] ?? [] as $violation) { + $violations[] = [$violation->decision, $violation->derivedFrom !== null, new Finding( + $result->path, + $violation->line, + $violation->message, + $violation->fixable && $violation->risk === null, + $violation->offset === null ? null : [$violation->offset, (int) $violation->length], + )]; + } + + $files[] = [$result->path, $violations, $result->changed, $result->syntaxError !== null, $result->failure !== null]; + } + + return self::collect($files); + } + + + /** + * The report of `check --format json`. + * @param array $report + */ + public static function fromJson(array $report): self + { + $files = []; + foreach (is_array($report['files'] ?? null) ? $report['files'] : [] as $file) { + $violations = []; + foreach ($file['violations'] ?? [] as $violation) { + $violations[] = [(string) $violation['decision'], $violation['derivedFrom'] !== null, new Finding( + (string) $file['path'], + (int) $violation['line'], + (string) $violation['message'], + $violation['fixable'] && $violation['risk'] === null, + isset($violation['offset']) ? [(int) $violation['offset'], (int) $violation['length']] : null, + )]; + } + + $files[] = [ + (string) $file['path'], + $violations, + (bool) $file['changed'], + $file['syntaxError'] !== null, + $file['failure'] !== null, + ]; + } + + return self::collect($files); + } + + + /** + * @param list, bool, bool, bool}> $files path, the violations + * (decision, derived, what it reports), changed, unparsed, failed + */ + private static function collect(array $files): self + { + $places = $fixable = $left = $syntaxErrors = $failed = $findings = []; + $changed = 0; + foreach ($files as [$path, $violations, $isChanged, $unparsed, $isFailed]) { + $changed += (int) $isChanged; + if ($unparsed) { + $syntaxErrors[] = $path; + } elseif ($isFailed) { + $failed[] = $path; + } + + foreach ($violations as [$decision, $derived, $finding]) { + if ($derived) { + continue; + } + + $places[$decision][$path] = ($places[$decision][$path] ?? 0) + 1; + if (count($findings[$decision] ?? []) < self::KeptFindings) { + $findings[$decision][] = $finding; + } + + if ($finding->fixable) { + $fixable[$decision][$path] = true; + } else { + $left[$decision][$path] = ($left[$decision][$path] ?? 0) + 1; + } + } + } + + return new self($places, $fixable, $left, $changed, $syntaxErrors, $failed, $findings); + } + + + /** + * The decisions the code disagrees with somewhere. + * @return list + */ + public function findResidue(): array + { + return array_keys($this->places); + } + + + /** A place the decision reports, a fixable one where there is one; null for none. */ + public function findExample(string $decision): ?Finding + { + $findings = $this->findings[$decision] ?? []; + return array_find($findings, fn(Finding $finding) => $finding->fixable) ?? $findings[0] ?? null; + } + + + /** How many files the decision reports in. */ + public function countFiles(string $decision): int + { + return count($this->places[$decision] ?? []); + } +} diff --git a/src/Measuring/ProbeRunner.php b/src/Measuring/ProbeRunner.php new file mode 100644 index 0000000000..3f0204f3cd --- /dev/null +++ b/src/Measuring/ProbeRunner.php @@ -0,0 +1,26 @@ +> $probes key => the configuration + * @return array by the key of the probe + * @throws ConfigurationException + */ + function run(array $probes): array; +} diff --git a/src/Measuring/ProcessProbeRunner.php b/src/Measuring/ProcessProbeRunner.php new file mode 100644 index 0000000000..01f6f1d6df --- /dev/null +++ b/src/Measuring/ProcessProbeRunner.php @@ -0,0 +1,143 @@ + the PHP and the script of DressCode */ + private array $command, + private int $processors, + /** @var ?\Closure(int, int): void the probes done and all of them, told at every look while they run */ + private ?\Closure $onProgress = null, + ) { + } + + + /** + * @param ?\Closure(int, int): void $onProgress + * @param ?int $processors how many the probes share; all of the machine by default + */ + public static function create(string $root, ?\Closure $onProgress = null, ?int $processors = null): self + { + $command = [...WorkerPool::buildPhpCommand() ?? [PHP_BINARY], WorkerPool::locateProgram()]; + return new self($root, $command, $processors ?? WorkerPool::detectCpuCount(), $onProgress); + } + + + public function run(array $probes): array + { + if (getenv(self::ProbeVariable) !== false) { + throw new \LogicException('A probe of `dresscode init` starts no probes of its own.'); + } + + $dir = sys_get_temp_dir() . '/dresscode-probes-' . uniqid(more_entropy: true); + FileSystem::createDir($dir); + $parallel = max(1, min($this->processors, count($probes))); + $jobs = max(1, intdiv($this->processors, $parallel)); + $queue = array_keys($probes); + $running = $results = []; + try { + while ($queue || $running) { + while ($queue && count($running) < $parallel) { + $key = array_shift($queue); + $running[$key] = $this->start($probes[$key], $dir . '/' . md5((string) $key), $jobs); + } + + foreach ($running as $key => [$process, $file, $started]) { + // the exit code is told once, by the first look after the end, and proc_close() then has none + $status = proc_get_status($process); + if ($status['running'] && microtime(as_float: true) - $started > self::Timeout) { + throw new \RuntimeException('A check of the measuring did not end within ' . self::Timeout / 60 . ' minutes.'); + } elseif ($status['running']) { + continue; + } + + proc_close($process); + unset($running[$key]); + $results[$key] = self::readResult($file, $status['exitcode']); + } + + ($this->onProgress)?->__invoke(count($results), count($probes)); + if ($running) { + usleep(self::PollInterval); + } + } + } finally { + foreach ($running as [$process]) { + ProcessRunner::killTree(proc_get_status($process)['pid']); + proc_close($process); + } + + FileSystem::delete($dir); + } + + return $results; + } + + + /** + * @param array $config + * @return array{resource, string, float} the process, the base name of its files and when it started + */ + private function start(array $config, string $file, int $jobs): array + { + FileSystem::write("$file.neon", Neon::encode($config, blockMode: true)); + $null = PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null'; + $process = proc_open( + [...$this->command, 'check', '--format=json', "--jobs=$jobs", '--no-cache', "--root=$this->root", "--config=$file.neon"], + [0 => ['file', $null, 'r'], 1 => ['file', "$file.json", 'w'], 2 => ['file', "$file.err", 'w']], + $pipes, + $this->root, + [...getenv(), self::ProbeVariable => '1'], + ); + if ($process === false) { + throw new \RuntimeException('A process of `check` could not be started.'); + } + + return [$process, $file, microtime(as_float: true)]; + } + + + private static function readResult(string $file, int $exit): ProbeResult + { + $report = json_decode((string) @file_get_contents("$file.json"), associative: true); // @ written by the process + if ($exit > 2 || !is_array($report) || !is_array($report['files'] ?? null)) { // 2 is a file a rule failed in, which the report carries + $error = trim((string) @file_get_contents("$file.err")); // @ written by the process + throw new \RuntimeException("A check of the measuring ended with exit code $exit: " . ($error ?: 'no output')); + } + + return ProbeResult::fromJson($report); + } +} diff --git a/src/Measuring/ProjectFacts.php b/src/Measuring/ProjectFacts.php new file mode 100644 index 0000000000..ccdb8ddc6a --- /dev/null +++ b/src/Measuring/ProjectFacts.php @@ -0,0 +1,97 @@ + the packages composer.json requires, and its own name */ + private array $required, + /** @var array the packages the lock file, or what Composer installed, lists */ + private array $packages, + public ?FormatterConfig $formatterConfig, + /** the versions of PHP the code is written for, as a Composer constraint */ + public string $phpTarget, + public PhpVersionSource $phpTargetSource, + ) { + } + + + public static function detect(string $root, ?FormatterConfig $formatterConfig = null): self + { + $composer = ComposerJson::read(ComposerJson::findFile($root)) ?? []; + $name = is_string($composer['name'] ?? null) ? $composer['name'] : null; + [$phpTarget, $source] = (new RunnerFactory)->getPhpTarget(new Config, $root); + return new self( + $root, + $name, + [...($name === null ? [] : [$name]), ...array_map(strval(...), array_keys((array) ($composer['require'] ?? [])))], + self::readPackages($root), + $formatterConfig, + $phpTarget, + $source, + ); + } + + + public function hasPackage(string $name): bool + { + return isset($this->packages[$name]); + } + + + /** Whether the project uses Laravel: it is or requires a package of Laravel, or Laravel Pint formats it. */ + public function usesLaravel(): bool + { + return array_any($this->required, fn(string $package) => str_starts_with($package, 'laravel/')) + || $this->hasPackage('laravel/pint') + || is_file("$this->root/pint.json"); + } + + + /** + * The standard of the preset Laravel Pint formats the project with, the formatter chosen deciding: its preset where + * it is Pint, `laravel` where no formatter has a configuration and the project has Pint, which formats with it then; + * null for another formatter, a configuration that cannot be read, and a preset no standard is, `empty` among them. + */ + public function findPintStandard(): ?string + { + return match (true) { + $this->formatterConfig !== null + => $this->formatterConfig->formatter === Formatter::Pint ? $this->formatterConfig->standard : null, + $this->hasPackage('laravel/pint') => Pint::findStandard(null), + default => null, + }; + } + + + /** + * The packages the project has, told by the lock file beside its composer.json or, without one, by what Composer + * installed into the vendor directory it configures. + * @return array + */ + private static function readPackages(string $root): array + { + $packages = ProjectPackages::read($root); + return array_fill_keys(array_keys($packages->readLockedVersions() ?? $packages->installed), true); + } +} diff --git a/src/Measuring/Proposal.php b/src/Measuring/Proposal.php new file mode 100644 index 0000000000..a2ebaed0bd --- /dev/null +++ b/src/Measuring/Proposal.php @@ -0,0 +1,452 @@ + path => what the file writes for it */ + public private(set) array $values = []; + + /** what a check of the configuration over the whole scope says, null before it ran */ + public private(set) ?Verification $verification = null; + + /** what the same check says with the types of the code, which the measuring goes without; null before it ran */ + public private(set) ?Verification $typesVerification = null; + + /** @var array path => how it was measured and what its probes said */ + private array $measured = []; + + + private function __construct( + public readonly ProjectFacts $project, + public readonly Scope $scope, + /** the files the decisions are measured on */ + public readonly FileSample $sample, + public readonly NamespaceDeclarations $declarations, + public readonly StandardChoice $standard, + private readonly ProbeRunner $runner, + private readonly Budget $budget, + /** @var ?\Closure(string, string): void */ + private readonly ?\Closure $onPhase, + ) { + } + + + /** + * Measures the residue of the standard over a sample of the scope; a file that does not parse leaves the scope. + * @param ?list $presets the standard, named as the configuration names it; null to choose + * @param ?int $budget the files the first fix may change; by default a share of the scope + * @param ?\Closure(string, string): void $onPhase the phase, `Measuring` or `Checking`, and what it does now, said + * before it starts + * @throws ConfigurationException + */ + public static function measure( + ProjectFacts $project, + Scope $scope, + ProbeRunner $runner, + ?array $presets = null, + ?int $budget = null, + ?\Closure $onPhase = null, + ): self + { + $onPhase?->__invoke('Measuring', 'reading the declarations of the namespaces'); + $declarations = NamespaceDeclarations::collect($project->root, $scope->files); + $scope->leaveOut($declarations->unparsed, LeftOutReason::Unparsed); + $sample = FileSample::pick($project->root, $scope->files); + + $candidates = StandardChoice::listCandidates($project, $presets); + $onPhase?->__invoke('Measuring', sprintf( + 'checking %d files against %s', + count($sample->files), + count($candidates) > 1 ? 'every standard' : implode(', ', $candidates[0]), + )); + $base = new Draft()->setScope($sample->files, fileExtensions: $scope->listExtensions()); + if ($declarations->unparsed === []) { // as the configuration written resolves the names + $base->setNamespaces($declarations->functions, $declarations->constants); + } + + $base = $base->toArray(); + $standard = StandardChoice::measure($project, $presets, $runner, $base); + $scope->leaveOut($standard->unparsed, LeftOutReason::Unparsed); + + $proposal = new self( + $project, + $scope, + $sample, + $declarations, + $standard, + $runner, + new Budget(count($scope->files), $budget), + $onPhase, + ); + $onPhase?->__invoke('Measuring', 'measuring ' . count($standard->residue) . ' decisions over ' . count($sample->files) . ' files'); + $proposal->measureResidue(); + return $proposal; + } + + + /** + * Measures the project again against another standard, as the answer to a close call of standards asks. + * @param list $presets + * @throws ConfigurationException + */ + public function measureAgainst(array $presets): self + { + return self::measure($this->project, $this->scope, $this->runner, $presets, $this->budget->files, $this->onPhase); + } + + + /** + * Measures the decisions of the residue in two layers, what the file writes for those of the first laid under the + * probes of the second, each probe having its decision alone, no standard beside it. + * @throws ConfigurationException + */ + private function measureResidue(): void + { + $base = [...array_diff_key($this->buildProbeConfig(), ['use' => true]), 'paths' => $this->sample->files]; + $residue = $this->standard->residue; + foreach ([array_intersect(MeasurementPlan::FirstLayer, $residue), array_diff($residue, MeasurementPlan::FirstLayer)] as $layer) { + $plans = $probes = []; + foreach ($layer as $path) { + $plans[$path] = MeasurementPlan::fromDecision( + $this->standard->decisions[$path]->decision, + StandardChoice::listStandardValues($path), + ); + $probes += $plans[$path]->buildProbes($base); + } + + $probeResults = $probes ? $this->runner->run($probes) : []; + foreach ($plans as $path => $plan) { + $own = array_filter($probeResults, fn(string $key) => str_starts_with($key, "$path="), ARRAY_FILTER_USE_KEY); + $this->measured[$path] = new MeasuredDecision($plan, $own); + $proposed = $plan->decide($own, count($this->sample->files), $this->standard->isStandard(...)); + if ($proposed === null) { + continue; + } + + $this->values[$proposed->path] = $proposed; + if (in_array($path, MeasurementPlan::FirstLayer, true) && !$proposed->isKept()) { + $base = Helpers::placeValue($base, $proposed->path, $proposed->value); + } + } + } + } + + + /** + * Writes the value the user gives a decision, whatever was measured, which the budget then never leaves unchecked. + * @throws ConfigurationException + */ + public function answer(string $path, mixed $value, string $comment = 'your answer'): void + { + $standard = $this->standard->decisions[$path] ?? throw new ConfigurationException("Decision `$path` is not one the standard knows."); + $standard->decision->accept($value); + $this->values[$path] = new ProposedValue($path, $value, $comment, ValueOrigin::Answered); + } + + + /** + * Checks the configuration over the whole scope and leaves unchecked the decisions whose fix would change more files + * than the budget allows, the costliest first, then checks it again until the rest fits; a file such a check finds + * unparsable leaves the scope, and the last check is what a fix would change and what it leaves. + * @throws ConfigurationException + */ + public function verify(): Verification + { + $this->onPhase?->__invoke('Checking', 'the configuration over all ' . count($this->scope->files) . ' files'); + $check = fn() => $this->runner->run(['scope' => $this->buildProbeConfig()])['scope']; + $result = $check(); + $answered = array_keys(array_filter($this->values, fn(ProposedValue $proposed) => $proposed->origin === ValueOrigin::Answered)); + do { + // a decision left unchecked changes what the others would fix, so the budget is applied until it holds + $dropped = $this->budget->findDropped($result->fixable, $answered); + foreach ($dropped as $path) { + $this->values[$path] = ProposedValue::overBudget( + $path, + $this->values[$path]->value ?? $this->standard->decisions[$path]->value->toWrittenData(), + count($result->fixable[$path]), + count($this->scope->files), + $result->findExample($path), + ); + } + + $this->scope->leaveOut($result->syntaxErrors, LeftOutReason::Unparsed); + if ($dropped || $result->syntaxErrors) { + $result = $check(); + } + } while ($dropped); + + return $this->verification = new Verification($result, $this->budget->files); + } + + + /** + * Takes answers to questions: writes the decisions they give, and tells whether the configuration changed, which + * a verification then checks; a standard to measure against instead is the caller's to measure. + * @throws ConfigurationException + */ + public function applyAnswers(Answer ...$answers): bool + { + $before = $this->buildProbeConfig(); + $values = []; + foreach ($answers as $answer) { + foreach ($answer->values as $path => $value) { + // answers adding to one list, each built from what was proposed before them, add up rather than replace each other + $earlier = $values[$path][0] ?? null; + $values[$path] = [ + is_array($earlier) && array_is_list($earlier) && is_array($value) && array_is_list($value) + ? array_values(array_unique([...$earlier, ...$value], SORT_REGULAR)) + : $value, + $answer->comment, + ]; + } + } + + foreach ($values as $path => [$value, $comment]) { + $this->answer($path, $value, $comment); + } + + return $this->buildProbeConfig() !== $before; + } + + + /** + * The files of the scope a fix under the value would rewrite, as the probes of the measuring tell, scaled from the + * sample to the scope; null for a decision or a value they did not measure. + */ + public function estimateFiles(string $path, mixed $value): ?int + { + $measured = $this->measured[$path] ?? null; + if ($measured === null) { + return null; + } + + $files = $measured->plan->countFiles($measured->results, $value); + return $files === null ? null : (int) round($files * count($this->scope->files) / max(1, count($this->sample->files))); + } + + + /** + * Whether the configuration asks for the types of the code, the project having PHPStan; the measuring goes without + * them, so a check with them may report what it did not. + */ + public function usesTypeAnalysis(): bool + { + return $this->project->hasPackage('phpstan/phpstan'); + } + + + /** + * Checks the configuration as written, the types of the code among it, once over the whole scope, as the measuring + * and the verification go without them, which would start PHPStan in every probe; null where it asks for no types. + */ + public function verifyWithTypes(): ?Verification + { + if (!$this->usesTypeAnalysis()) { + return null; + } + + $this->onPhase?->__invoke('Checking', 'the configuration with the types of the code'); + $result = $this->runner->run(['types' => $this->toDraft()->toArray()])['types']; + return $this->typesVerification = new Verification($result, $this->budget->files); + } + + + /** + * The files a rule failed in under some probe of the measuring or of the checks of the configuration, whose places + * nothing counted, so that the decisions rest on the other files. + * @return list + */ + public function findFailedFiles(): array + { + $failed = [ + ...$this->standard->failed, + ...$this->verification->result->failed ?? [], + ...$this->typesVerification->result->failed ?? [], + ...array_merge(...array_map( + fn(MeasuredDecision $measured) => $measured->listFailedFiles(), + array_values($this->measured), + )), + ]; + $failed = array_values(array_unique($failed)); + sort($failed); + return $failed; + } + + + /** Whether the decisions rest on part of the code only, a rule having failed in some files. */ + public function isPartial(): bool + { + return $this->findFailedFiles() !== []; + } + + + /** + * The directories of the sample whose files write a decision of the file otherwise than the configuration does, in + * `RegionFiles` files at least and in `RegionShare` of those writing one value; an override would keep the value + * there, and the file writes none, as one style per project is what init proposes. + * @return list + */ + public function findRegions(): array + { + $regions = []; + foreach ($this->measured as $path => $measured) { + $plan = $measured->plan; + if ($plan->method !== MeasuringMethod::Values || !$plan->isFileProperty()) { + continue; + } + + $decision = $this->standard->decisions[$path]; + $written = isset($this->values[$path]) + ? $decision->decision->accept($this->values[$path]->value)->toData() + : $decision->value->toData(); + $own = array_find_key( + $plan->candidates, + fn(mixed $candidate) => $decision->decision->accept($candidate)->toData() === $written, + ); + if ($own === null) { // a tolerance, or `keep` + continue; + } + + $counts = []; + foreach ($plan->groupFilesByValue($measured->results) as $name => $files) { + foreach ($files as $file) { + $directory = $this->sample->findDirectory($file); + $counts[$directory][$name] = ($counts[$directory][$name] ?? 0) + 1; + } + } + + foreach ($counts as $directory => $byValue) { + arsort($byValue); + $name = array_key_first($byValue); + if ($directory !== '' && $name !== $own && $byValue[$name] >= self::RegionFiles + && $byValue[$name] >= self::RegionShare * array_sum($byValue) + ) { + $regions[] = new Region($directory, $path, $plan->candidates[$name], $byValue[$name], array_sum($byValue)); + } + } + } + + return $regions; + } + + + /** Whether the numbers of the measuring are estimates, the sample being smaller than the scope. */ + public function isSampled(): bool + { + return count($this->sample->files) < count($this->scope->files); + } + + + /** + * What has moved since the configuration that exists was written: every decision init would write otherwise, and + * those it sets apart from the standard while the code holds to the standard, which no residue shows. + * @return list + */ + public function findShifts(ResolvedConfig $current): array + { + $shifts = []; + foreach ($this->values as $path => $proposed) { + $value = ($current->decisions[$path] ?? null)?->value; + $measured = $this->standard->formatValue($path, $proposed->value); + if ($value?->toWrittenText() !== $measured) { + $cost = $value === null ? null : $this->estimateFiles($path, $value->toWrittenData()); + $shifts[] = new Shift(ShiftKind::Measured, $path, $value?->toWrittenText(), $measured, $cost, $proposed->comment); + } + } + + foreach ($current->decisions as $path => $decision) { + $standard = $this->standard->decisions[$path] ?? null; + if ( + $standard !== null + && $standard->inactive === null + && $decision->inactive === null + && !isset($this->values[$path]) + && !in_array($path, $this->standard->residue, true) + && $decision->value->toData() !== $standard->value->toData() + ) { + $shifts[] = new Shift( + ShiftKind::HeldToStandard, + $path, + $decision->value->toWrittenText(), + $standard->value->toWrittenText(), + ); + } + } + + return $shifts; + } + + + /** + * The configuration as `dresscode.neon` writes it, every value with what it rests on, over the scope as it stands: + * after `verify()` without the files its checks found unparsable. + */ + public function toDraft(): Draft + { + $failed = $this->findFailedFiles(); + $draft = new Draft('made to measure by `dresscode init`; run it again to see what has moved since, it writes nothing over this file' + . ($failed === [] ? '' : "\nnot measured: a rule failed in " . implode(', ', $failed) . ', so the decisions rest on the other files')); + $draft->setUse($this->standard->presets, $this->standard->describeReason()); + // a file that does not parse may declare what the lists lack, so the names are not resolved for certain then + if ($this->declarations->unparsed === []) { + $draft->setNamespaces( + $this->declarations->functions, + $this->declarations->constants, + $this->declarations->isEmpty() + ? "the namespaces of the scope declare no function and no constant, so an unqualified name in a namespace\n" + . 'is resolved for certain; one declared later is reported until it is listed' + : "the namespaces of the scope declare these functions and constants and no others, so an unqualified name\n" + . 'in a namespace is resolved for certain; one declared later is reported until it is listed', + ); + } + if ($this->usesTypeAnalysis()) { + $draft->setTypeAnalysis('phpstan/phpstan is installed, so the rules get the types of the code'); + } + + foreach ($this->values as $path => $proposed) { + $draft->setDecision($path, $proposed->value, $proposed->comment); + } + + $decisions = $this->standard->decisions; + $this->scope->writeInto( + $draft, + $this->project->formatterConfig, + fn(string $path) => isset($decisions[$path]) && $decisions[$path]->decision->takesKeep() && !$decisions[$path]->value->isKept(), + ); + return $draft; + } + + + /** + * The configuration a probe checks with, the types left out, which would start PHPStan in every probe. + * @return array + */ + private function buildProbeConfig(): array + { + return array_diff_key($this->toDraft()->toArray(), ['typeAnalysis' => true]); + } +} diff --git a/src/Measuring/ProposedValue.php b/src/Measuring/ProposedValue.php new file mode 100644 index 0000000000..69eb3eb802 --- /dev/null +++ b/src/Measuring/ProposedValue.php @@ -0,0 +1,55 @@ +value === 'keep'; + } +} diff --git a/src/Measuring/Question.php b/src/Measuring/Question.php new file mode 100644 index 0000000000..6fac2304ab --- /dev/null +++ b/src/Measuring/Question.php @@ -0,0 +1,37 @@ + the recommended one first */ + public array $answers, + /** how to answer it without a terminal: the line of `dresscode.neon` or the command that does it */ + public string $instruction, + /** a place of the code it is about */ + public ?Finding $example = null, + /** the files the costliest answer rewrites, by which the questions are ordered */ + public int $files = 0, + ) { + } + + + public function getRecommended(): Answer + { + return $this->answers[0]; + } +} diff --git a/src/Measuring/Questions.php b/src/Measuring/Questions.php new file mode 100644 index 0000000000..d7c3dd68bb --- /dev/null +++ b/src/Measuring/Questions.php @@ -0,0 +1,312 @@ + the decisions a question was found about, asked or not */ + private array $found = []; + + + public function __construct( + /** the rules of the formatter of the project, which a value of the code may contradict */ + private readonly ?Translation $formatterRules = null, + ) { + } + + + /** A close call of standards nothing of the project decided, asked before the verification, as an answer measures again. */ + public static function findStandardQuestion(Proposal $proposal): ?Question + { + $standard = $proposal->standard; + if (!$standard->alternatives) { + return null; + } + + $chosen = implode(', ', $standard->presets); + $describe = fn(string $name) => sprintf('%s, a fix under it changes %d files', $name, $standard->changedFiles[$name] ?? 0); + $answers = [new Answer($describe($chosen))]; + foreach ($standard->alternatives as $alternative) { + $answers[] = new Answer($describe($alternative), measureAgainst: [$alternative]); + } + + return new Question( + "Your code is as near to $chosen as to " . implode(' and ', $standard->alternatives) . '; which standard should it follow?', + $answers, + "`dresscode init --use {$standard->alternatives[0]}` measures against {$standard->alternatives[0]} instead, where there is no `dresscode.neon` yet", + ); + } + + + /** + * Asks round after round what the verified proposal leaves to the user, and checks the configuration again after + * a round whose answers changed it, until a round changes nothing or nothing is left to ask. + * @param \Closure(list, bool): list $answer answers a round, told whether it follows from the + * answers to the rounds before + * @return list the questions asked + * @throws ConfigurationException + */ + public function askAll(Proposal $proposal, \Closure $answer): array + { + $asked = []; + while ($round = $this->findNext($proposal)) { + $answers = $answer($round, $asked !== []); + $asked = [...$asked, ...$round]; + if (!$proposal->applyAnswers(...$answers)) { + break; + } + + $proposal->verify(); + } + + return $asked; + } + + + /** + * The questions of decisions the verified proposal leaves to the user that no call before found, the costliest + * first: at most `MaxDecisions` of them the first time, every one later, as it follows from the answers given. + * @return list + */ + public function findNext(Proposal $proposal): array + { + $first = !$this->found; + $questions = array_diff_key([ + ...self::findOverBudget($proposal), + ...self::findConflicts($proposal, $this->formatterRules), + ...self::findNamePatterns($proposal), + ], $this->found); + uasort($questions, fn(Question $a, Question $b) => $b->files <=> $a->files); + $this->found += array_fill_keys(array_keys($questions), true); + return array_values($first ? array_slice($questions, 0, self::MaxDecisions) : $questions); + } + + + /** + * A value the code or the standard holds to whose fix would rewrite more files than the budget allows. + * @return array by the path of the decision + */ + private static function findOverBudget(Proposal $proposal): array + { + $questions = []; + foreach ($proposal->values as $path => $proposed) { + if ($proposed->origin === ValueOrigin::OverBudget) { + $wanted = $proposal->standard->formatValue($path, $proposed->wanted); + $questions[$path] = new Question( + "Check `$path: $wanted`? A fix rewrites $proposed->cost files, more than the budget allows.", + [ + new Answer("don't check it"), + new Answer("$wanted everywhere, a fix rewrites $proposed->cost files", [$path => $proposed->wanted]), + ], + "`$path: $wanted` in `dresscode.neon` checks it too; its fix changes $proposed->cost files", + $proposed->example, + $proposed->cost, + ); + } + } + + return $questions; + } + + + /** + * A value the code holds to that the configuration of the formatter contradicts. + * @return array by the path of the decision + */ + private static function findConflicts(Proposal $proposal, ?Translation $formatterRules): array + { + $config = $proposal->project->formatterConfig; + if ($formatterRules === null || $config === null) { + return []; + } + + $questions = []; + foreach ($formatterRules->collectDecisions() as $path => $foreign) { + $proposed = $proposal->values[$path] ?? null; + $decision = $proposal->standard->decisions[$path]->decision ?? null; + if ($proposed === null || $decision === null || $proposed->origin !== ValueOrigin::Measured || $proposed->isKept()) { + continue; + } + + try { + if ($decision->accept($foreign)->toData() === $decision->accept($proposed->value)->toData()) { + continue; + } + } catch (ConfigurationException) { + continue; + } + + $files = $proposal->estimateFiles($path, $foreign); + if ($files !== null) { + $questions[$path] = self::createConflict($proposal, $config, $path, $foreign, $files); + } + } + + return $questions; + } + + + private static function createConflict(Proposal $proposal, FormatterConfig $config, string $path, mixed $foreign, int $files): Question + { + $proposed = $proposal->values[$path]; + $formatterValue = $proposal->standard->formatValue($path, $foreign); + $measuredValue = $proposal->standard->formatValue($path, $proposed->value); + $about = $proposal->isSampled() ? 'about ' : ''; + return new Question( + "`$config->file` wants `$path: $formatterValue`, your code writes $measuredValue ($proposed->comment); which should the file say?", + [ + new Answer("$measuredValue, as your code writes it", [$path => $proposed->value], $proposed->comment), + new Answer( + "$formatterValue, as `$config->file` says, a fix rewrites $about" . self::formatFiles($files), + [$path => $foreign], + ), + ], + "`$path: $formatterValue` in `dresscode.neon` says what `$config->file` says; its fix changes $about" + . self::formatFiles($files), + files: $files, + ); + } + + + /** + * Names written in another case that share a prefix or a suffix, which `naming.except` may let pass. + * @return array by the path of the decision + */ + private static function findNamePatterns(Proposal $proposal): array + { + $verification = $proposal->verification ?? throw new \LogicException('The proposal is not verified.'); + $questions = []; + $codes = []; + foreach (NameCasingRule::getDecisions() as $decision) { + $path = $decision->path; + $names = $files = []; + foreach ($verification->result->findings[$path] ?? [] as $finding) { + if ($finding->fixable || $finding->span === null) { + continue; + } + + // what the rule reports begins with the name, a constant going on with its value + $file = $finding->file; + $codes[$file] ??= (string) @file_get_contents($proposal->project->root . "/$file"); // @ file may have been deleted meanwhile + if (preg_match('~\$?(\w+)~A', substr($codes[$file], ...$finding->span), $m)) { + $names[] = $m[1]; + $files[$file] = true; + } + } + + $pattern = count($names) < self::MinNames ? null : self::findPattern($names); + if ($pattern !== null) { + $questions[$path] = self::createNamePatternQuestion($proposal, $path, $names, $pattern, count($files)); + } + } + + return $questions; + } + + + /** + * @param list $names + * @param array{string, string} $pattern the regular expression, what it allows + */ + private static function createNamePatternQuestion(Proposal $proposal, string $path, array $names, array $pattern, int $files): Question + { + [$regex, $allows] = $pattern; + $kind = substr($path, strlen('naming.')); + $except = (array) ($proposal->values['naming.except']->value ?? $proposal->standard->decisions['naming.except']->value->toData() ?? []); + return new Question( + sprintf('%d %s names %s, which no rule renames; how should they be checked?', count($names), $kind, $allows), + [ + new Answer('list them in the baseline'), + new Answer( + "allow names that $allows, now and later", + ['naming.except' => [...$except, $regex]], + sprintf('the %d %s names that %s, which no rule renames', count($names), $kind, $allows), + ), + new Answer("don't check $kind names", [$path => 'keep']), + ], + "`naming.except: [$regex]` in `dresscode.neon` lets the $kind names that $allows pass now and later, rather than the baseline listing those of today", + $proposal->verification?->result->findExample($path), + $files, + ); + } + + + /** + * The suffix or the prefix every name shares, cut at the boundary of a word, as the regular expression of + * `naming.except` and what it allows: names that "end with `_`". + * @param list $names + * @return ?array{string, string} + */ + private static function findPattern(array $names): ?array + { + if (!$names) { + return null; + } + + $shortest = min(array_map(strlen(...), $names)); + $suffix = $prefix = ''; + for ($i = 1; $i < $shortest; $i++) { + $tail = substr($names[0], -$i); + if (!array_all($names, fn(string $name) => str_ends_with($name, $tail))) { + break; + } elseif (array_all($names, fn(string $name) => self::isWordBoundary($name, strlen($name) - $i))) { + $suffix = $tail; + } + } + + for ($i = 1; $i < $shortest; $i++) { + $head = substr($names[0], 0, $i); + if (!array_all($names, fn(string $name) => str_starts_with($name, $head))) { + break; + } elseif (array_all($names, fn(string $name) => self::isWordBoundary($name, $i))) { + $prefix = $head; + } + } + + return match (true) { + strlen($suffix) >= strlen($prefix) && $suffix !== '' => ['~' . preg_quote($suffix, '~') . '$~', "end with `$suffix`"], + $prefix !== '' => ['~^' . preg_quote($prefix, '~') . '~', "start with `$prefix`"], + default => null, + }; + } + + + private static function formatFiles(int $count): string + { + return match ($count) { + 0 => 'no file', + 1 => '1 file', + default => "$count files", + }; + } + + + /** Whether a word of the name ends before the offset: at an underscore or where a capital follows a lowercase letter. */ + private static function isWordBoundary(string $name, int $at): bool + { + return $name[$at - 1] === '_' || $name[$at] === '_' || (ctype_upper($name[$at]) && !ctype_upper($name[$at - 1])); + } +} diff --git a/src/Measuring/Region.php b/src/Measuring/Region.php new file mode 100644 index 0000000000..20672e4486 --- /dev/null +++ b/src/Measuring/Region.php @@ -0,0 +1,29 @@ + the files in the paths git does not track and would add, which a run takes and the measuring does not */ + public private(set) array $untracked = []; + + /** @var ?array what `listExcludePaths()` gives, until the scope changes */ + private ?array $excluded = null; + + + private function __construct( + /** @var list sorted, relative to the root */ + public private(set) array $files, + /** @var array file or directory relative to the root => why it is left out */ + public private(set) array $leftOut, + /** @var ?list the paths the scope is written as; null for the entries of the root that hold its files */ + private readonly ?array $paths, + ) { + } + + + /** + * The files of the extensions the repository tracks under the root, those the default exclusions name dropped without + * a word, the directories and files git ignores that hold PHP, and the files it would add that lie in the paths, which + * a run takes too, not measured; null where there is no git, the root lies in no repository or the repository tracks + * none of its code, as a project in a directory another repository ignores. With the paths given, only what lies + * under them, the scope written as those paths. + * @param list $extensions + * @param ?list $paths + */ + public static function fromGit(string $root, array $extensions, ?array $paths = null): ?self + { + $tracked = self::runGit($root, ['ls-files', '-z', '--', ...array_map(fn(string $ext) => "*.$ext", $extensions)]); + if ($tracked === null || $tracked === '') { + return null; + } + + $under = fn(string $file) => $paths === null + || array_any($paths, fn(string $path) => $path === '.' || $file === $path || str_starts_with($file, "$path/")); + $files = array_values(array_filter( + explode("\0", $tracked), + fn(string $file) => $file !== '' + && $under($file) + && !Helpers::matchesAny(Config::DefaultExcludePaths, $file) + && is_file("$root/$file"), + )); + sort($files, SORT_STRING); + [$files, $leftOut] = self::classifyFiles($root, $files, self::findGeneratedAttribute($root, $files)); + + $ignored = self::runGit($root, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']) ?? ''; + foreach (explode("\0", $ignored) as $entry) { + $entry = rtrim($entry, '/'); + if ( + $entry !== '' + && $under($entry) + && !Helpers::matchesAny(Config::DefaultExcludePaths, $entry) + && self::holdsCode("$root/$entry", $extensions) + ) { + $leftOut[$entry] = LeftOutReason::Ignored; + } + } + + $scope = new self($files, $leftOut, $paths); + $untracked = self::runGit( + $root, + ['ls-files', '-z', '--others', '--exclude-standard', '--', ...array_map(fn(string $ext) => "*.$ext", $extensions)], + ) ?? ''; + $listed = $scope->listPaths(); + $scope->untracked = array_values(array_filter( + explode("\0", $untracked), + fn(string $file) => $file !== '' + && !Helpers::matchesAny(Config::DefaultExcludePaths, $file) + && array_any($listed, fn(string $path) => $path === '.' || $file === $path || str_starts_with($file, "$path/")), + )); + return $scope; + } + + + /** + * The files of the extensions in the directories the autoload of the project names and in the usual ones beside them, + * for a project without git. + * @param list $extensions + */ + public static function fromAutoload(string $root, array $extensions): self + { + return self::fromPaths($root, self::findAutoloadPaths($root), $extensions); + } + + + /** + * The scope of a project: what its formatter checks where its configuration names the paths, else what git tracks, + * else what the autoload names, without what the formatter leaves out. + */ + public static function fromProject(string $root, ?FormatterConfig $formatterConfig): self + { + $paths = $formatterConfig?->paths ?: null; + $extensions = $formatterConfig->extensions ?? self::Extensions; + $scope = self::fromGit($root, $extensions, $paths) + ?? ($paths === null ? self::fromAutoload($root, $extensions) : self::fromPaths($root, $paths, $extensions)); + if ($formatterConfig !== null) { + $scope->leaveOut(array_values(array_filter($scope->files, $formatterConfig->excludes(...))), LeftOutReason::Foreign); + } + + return $scope; + } + + + /** + * The files the configuration checks, those an override gives decisions of its own left out, as what they keep is + * no measure of the rest. + */ + public static function fromConfig(string $root, Config $config): self + { + $factory = new RunnerFactory; + $files = $factory->createFileFinder($factory->resolve($config, $root))->findFiles($config->paths ?: ['.']); + $scope = new self($files, [], $config->paths ?: ['.']); + $scope->leaveOut( + array_values(array_filter( + $files, + fn(string $file) => array_any($config->overrides, fn(Override $override) => Helpers::matchesAny($override->paths, $file)), + )), + LeftOutReason::Overridden, + ); + return $scope; + } + + + /** + * The files of the extensions under the paths given. + * @param list $paths + * @param list $extensions + */ + public static function fromPaths(string $root, array $paths, array $extensions): self + { + $factory = new RunnerFactory; + $files = $factory->createFileFinder($factory->resolve(new Config(fileExtensions: $extensions), $root)); + [$files, $leftOut] = self::classifyFiles($root, $files->findFiles($paths), []); + return new self($files, $leftOut, $paths); + } + + + /** + * The directories the autoload of the project names, the usual ones the root has besides them, and the root itself + * when neither says anything, with the files the autoload names one by one outside them. A directory that lies in + * another one is already in the scope, and one that does not exist would only make the run throw. + * @return list + */ + private static function findAutoloadPaths(string $root): array + { + $composerFile = ComposerJson::findFile($root); + $paths = ComposerJson::detectAutoloadPaths($composerFile, $root); + foreach (self::Paths as $name) { + // a pattern is matched against the names of the root, never against a path of its own, or a root + // holding a bracket or a star would be part of the pattern and quietly match nothing + $found = str_contains($name, '*') + ? array_values(array_filter(scandir($root) ?: [], fn(string $entry) => fnmatch($name, $entry) && is_dir("$root/$entry"))) + : (is_dir("$root/$name") ? [$name] : []); + $paths = [...$paths, ...$found]; + } + + $scope = []; + foreach (array_unique($paths) as $path) { + foreach ($paths as $other) { + if ($other !== $path && ($other === '.' || str_starts_with("$path/", "$other/"))) { + continue 2; + } + } + + $scope[] = $path; + } + + $scope = $scope ?: ['.']; + foreach (ComposerJson::detectAutoloadFiles($composerFile, $root) as $file) { + if (is_file("$root/$file") && !array_any($scope, fn(string $path) => $path === '.' || str_starts_with($file, "$path/"))) { + $scope[] = $file; + } + } + + sort($scope, SORT_STRING); + return $scope; + } + + + /** + * The files of the scope and those left out of it for being a test in the format PHPT, which is no PHP, the data of + * tests or written by a generator. + * @param list $files + * @param array $generated the files `.gitattributes` marks as generated + * @return array{list, array} + */ + private static function classifyFiles(string $root, array $files, array $generated): array + { + $kept = $leftOut = []; + foreach ($files as $file) { + $reason = match (true) { + str_ends_with($file, '.phpt') && self::isPhptTest("$root/$file") => LeftOutReason::PhptTest, + array_intersect(self::TestDataDirs, explode('/', $file)) !== [] => LeftOutReason::TestData, + isset($generated[$file]) || FileSample::isGenerated("$root/$file") => LeftOutReason::Generated, + default => null, + }; + if ($reason === null) { + $kept[] = $file; + } else { + $leftOut[$file] = $reason; + } + } + + return [$kept, $leftOut]; + } + + + /** Whether the head of the file has a line of a section of PHPT, `--FILE--`, which no PHP file starts with. */ + private static function isPhptTest(string $file): bool + { + $head = @file_get_contents($file, length: 1024); // @ file may have been deleted meanwhile + return $head !== false && preg_match('~^--[A-Z][A-Z_]*--\r?$~m', $head) === 1; + } + + + /** + * The extensions of the files of the scope, `php` first. + * @return list + */ + public function listExtensions(): array + { + $extensions = array_unique(['php', ...array_map(fn(string $file) => pathinfo($file, PATHINFO_EXTENSION), $this->files)]); + return array_values($extensions); + } + + + /** Whether the scope is all git tracks under the root, not narrowed to the paths of a formatter or of the autoload. */ + public function isFromGit(): bool + { + return $this->paths === null; + } + + + /** + * Leaves out the files given, those of them the scope has, for the reason given. + * @param list $files relative to the root + */ + public function leaveOut(array $files, LeftOutReason $reason): void + { + $files = array_intersect($this->files, $files); + $this->files = array_values(array_diff($this->files, $files)); + $this->leftOut += array_fill_keys($files, $reason); + $this->excluded = null; + } + + + /** + * Writes the scope into the configuration, its paths, what it excludes and its extensions, and with the formatter + * the overrides that keep the decisions a rule of it stands for in the files it leaves out of that rule. + * @param \Closure(string): bool $keepable whether the decision of the path is kept there + */ + public function writeInto(Draft $draft, ?FormatterConfig $config, \Closure $keepable): void + { + $draft->setScope( + $this->listPaths(), + array_map(fn(LeftOutReason $why) => $why->value, $this->listExcludePaths()), + $this->listExtensions(), + ); + if ($config === null) { + return; + } + + $translator = new Translator; + foreach (array_keys($config->ruleExcludePatterns) as $rule) { + $decisions = array_filter($translator->findPaths($rule), $keepable); + $files = array_values(array_filter($this->files, fn(string $file) => $config->excludesFromRule($file, $rule))); + if ($decisions && $files) { + $tree = []; + foreach ($decisions as $path) { + $tree = Helpers::placeValue($tree, $path, 'keep'); + } + + $draft->addOverride( + new Override($this->collapse($files), new Profile(decisions: $tree)), + "where $rule is left out by your formatter", + ); + } + } + } + + + /** + * The files of the scope given as the paths a configuration names: the highest directory of which every file of the + * scope is given, and a file where its directory holds others too. + * @param list $files + * @return list + */ + private function collapse(array $files): array + { + $all = $given = []; + $set = array_flip($files); + foreach ($this->files as $file) { + for ($dir = dirname($file); $dir !== '.'; $dir = dirname($dir)) { + $all[$dir] = ($all[$dir] ?? 0) + 1; + $given[$dir] = ($given[$dir] ?? 0) + (int) isset($set[$file]); + } + } + + $collapsed = []; + foreach ($files as $entry) { + while (($dir = dirname($entry)) !== '.' && $given[$dir] === $all[$dir]) { + $entry = $dir; + } + + $collapsed[$entry] = true; + } + + $collapsed = array_keys($collapsed); + sort($collapsed, SORT_STRING); + return $collapsed; + } + + + /** + * The paths the configuration names: the entries of the root that hold a file of the scope, directories and files + * alike, or what the autoload named. + * @return list + */ + public function listPaths(): array + { + if ($this->paths !== null) { + return $this->paths; + } + + $entries = array_values(array_unique(array_map(fn(string $file) => explode('/', $file, 2)[0], $this->files))); + sort($entries, SORT_STRING); + return $entries ?: ['.']; + } + + + /** + * What the configuration excludes inside its paths, each with the reason: a directory of the data of tests by its + * name, which stands for it at any depth, a directory where everything in it is generated, and a file otherwise. + * @return array pattern => why + */ + public function listExcludePaths(): array + { + if ($this->excluded !== null) { + return $this->excluded; + } + + // the files below each directory, all and by reason; the ignored entries hold files git does not list + $files = $byReason = []; + foreach ([...$this->files, ...array_keys($this->leftOut)] as $file) { + $reason = $this->leftOut[$file] ?? null; + for ($dir = dirname($file); $reason !== LeftOutReason::Ignored && $dir !== '.'; $dir = dirname($dir)) { + $files[$dir] = ($files[$dir] ?? 0) + 1; + $key = $reason->name ?? ''; + $byReason[$dir][$key] = ($byReason[$dir][$key] ?? 0) + 1; + } + } + + $paths = $this->listPaths(); + $extensions = $this->listExtensions(); + $isInside = fn(string $path) => array_any($paths, fn(string $listed) => $listed === '.' || str_starts_with($path, "$listed/")); + $excluded = []; + foreach ($this->leftOut as $entry => $reason) { + // a file of an extension the configuration does not take needs no exclusion + $extension = pathinfo($entry, PATHINFO_EXTENSION); + if ( + !($isInside($entry) || in_array($entry, $paths, true)) + || ($extension !== '' && !in_array($extension, $extensions, true)) + ) { + continue; + } elseif ($reason === LeftOutReason::TestData) { + $entry = array_find(explode('/', $entry), fn(string $segment) => in_array($segment, self::TestDataDirs, true)) ?? $entry; + } + + // a file generated or left out by the formatter stands for the highest directory inside the paths whose every + // file is left out for the same reason + while ( + ($reason === LeftOutReason::Generated || $reason === LeftOutReason::Foreign) + && $isInside($dir = dirname($entry)) + && ($byReason[$dir][$reason->name] ?? 0) === $files[$dir] + ) { + $entry = $dir; + } + + $excluded[$entry] = $reason; + } + + ksort($excluded, SORT_STRING); + return $this->excluded = $excluded; + } + + + /** + * The files `linguist-generated` of the attributes of git marks. + * @param list $files + * @return array + */ + private static function findGeneratedAttribute(string $root, array $files): array + { + $output = self::runGit($root, ['check-attr', '-z', '--stdin', 'linguist-generated'], implode("\0", $files)); + $fields = explode("\0", (string) $output); + $generated = []; + for ($i = 0; $i + 2 < count($fields); $i += 3) { // path, attribute, value + if ($fields[$i + 2] === 'set' || $fields[$i + 2] === 'true') { + $generated[$fields[$i]] = true; + } + } + + return $generated; + } + + + /** + * Whether the file is code of the extensions, or the directory holds some below the directories the defaults + * exclude. + * @param list $extensions + */ + private static function holdsCode(string $path, array $extensions): bool + { + if (is_file($path)) { + return in_array(pathinfo($path, PATHINFO_EXTENSION), $extensions, true); + } + + $finder = Finder::findFiles(array_map(fn(string $ext) => "*.$ext", $extensions)) + ->from($path) + ->descentFilter(fn(\SplFileInfo $dir) => !Helpers::matchesAny(Config::DefaultExcludePaths, $dir->getFilename())); + foreach ($finder as $ignored) { + return true; + } + + return false; + } + + + /** + * What git writes for the command run in the root; null where it fails, the root lying in no repository or git + * missing. + * @param list $args + */ + private static function runGit(string $root, array $args, string $input = ''): ?string + { + [$exit, $output] = ProcessRunner::run(['git', '-C', $root, ...$args], $root, self::GitTimeout, $input, errors: false); + return $exit === 0 ? $output : null; + } +} diff --git a/src/Measuring/Shift.php b/src/Measuring/Shift.php new file mode 100644 index 0000000000..0233abc167 --- /dev/null +++ b/src/Measuring/Shift.php @@ -0,0 +1,30 @@ + 'PER Coding Style', + 'psr12' => 'PSR-12', + 'nette' => 'the Nette Coding Standard', + 'symfony' => 'the Symfony Coding Standards', + 'laravel' => 'the style of Laravel Pint', + ]; + + + private function __construct( + /** @var list as the configuration names them */ + public array $presets, + public StandardReason $reason, + /** @var array standard => the files of the sample a fix under it alone rewrites, the fewest first */ + public array $changedFiles, + /** @var list the decisions the code departs from the chosen standard in */ + public array $residue, + /** @var array what the chosen standard decides */ + public array $decisions, + /** @var list the files the checks found unparsable */ + public array $unparsed, + /** @var list the standards as near to the code, which nothing of the project decided between */ + public array $alternatives = [], + /** @var list the files a rule failed in under some standard, which none of them counted */ + public array $failed = [], + /** a rule failed under every standard there was to choose from, so the one chosen may only seem the nearest */ + public bool $unsure = false, + ) { + } + + + /** + * The standards to check: the presets given, the one of Laravel Pint, else every standard, `laravel` only for a + * project of Laravel, its preset saying nothing of code without it. + * @param ?list $presets + * @return list> + */ + public static function listCandidates(ProjectFacts $project, ?array $presets): array + { + $pint = $presets === null ? $project->findPintStandard() : null; + return match (true) { + $presets !== null => [$presets], + $pint !== null => [[$pint]], + default => array_map( + fn(string $standard) => [$standard], + array_filter(CorePlugin::Standards, fn(string $standard) => $standard !== 'laravel' || $project->usesLaravel()), + ), + }; + } + + + /** + * Checks the candidates over the base, the configuration of the sample, and chooses. + * @param ?list $presets as the configuration names them; null to choose + * @param array $base + * @throws ConfigurationException + */ + public static function measure(ProjectFacts $project, ?array $presets, ProbeRunner $runner, array $base): self + { + $candidates = self::listCandidates($project, $presets); + $probes = []; + foreach ($candidates as $candidate) { + $probes[implode(', ', $candidate)] = [...$base, 'use' => $candidate]; + } + + $checks = $runner->run($probes); + // in the order of the candidates, whichever probe ended first, so that a tie always falls the same way + $changed = array_map(fn(string $name) => $checks[$name]->changedFiles, array_combine(array_keys($probes), array_keys($probes))); + asort($changed); + // a standard a rule failed under is not measured whole, so its count may be short; it is chosen only where all are + $whole = array_filter($changed, fn(string $standard) => $checks[$standard]->failed === [], ARRAY_FILTER_USE_KEY); + $unsure = $presets === null && count($candidates) > 1 && $whole === []; + [$chosen, $reason, $alternatives] = match (true) { + $presets !== null => [(string) array_key_first($checks), StandardReason::Given, []], + count($candidates) === 1 => [(string) array_key_first($checks), StandardReason::Formatter, []], + default => self::choose($whole ?: $changed, $project->formatterConfig?->standard), + }; + $presets = array_combine(array_keys($probes), $candidates)[$chosen]; + $decisions = (new RunnerFactory)->resolve(new Config(use: $presets), $project->root)->resolvedConfig->decisions; + return new self( + $presets, + $reason, + $changed, + array_values(array_filter($checks[$chosen]->findResidue(), fn(string $path) => isset($decisions[$path]))), + $decisions, + array_values(array_unique(array_merge(...array_map(fn(ProbeResult $check) => $check->syntaxErrors, array_values($checks))))), + $alternatives, + array_values(array_unique(array_merge(...array_map(fn(ProbeResult $check) => $check->failed, array_values($checks))))), + $unsure, + ); + } + + + /** + * The standard a fix changes the fewest files under; where the next one is within a tenth of it, the one the formatter + * of the project names if it is among them, else `perCs` among them, else the fewest. PSR-12 says nothing of what PHP + * brought since and so changes less, which is why it yields to PER Coding Style within `SuccessorShare`, unless the + * formatter names it. A close call the formatter does not decide leaves the other close standards as the alternatives. + * @param array $changed standard => the files a fix under it changes, the fewest first + * @param ?string $hint the standard the formatter of the project names + * @return array{string, StandardReason, list} + */ + private static function choose(array $changed, ?string $hint): array + { + $candidates = $changed; + if (isset($candidates['psr12'], $candidates['perCs']) + && $candidates['perCs'] <= self::SuccessorShare * $candidates['psr12'] + && $hint !== 'psr12' + ) { + unset($candidates['psr12']); + } + + $best = (string) array_key_first($candidates); + $close = array_keys(array_filter($candidates, fn(int $count) => $count <= self::CloseShare * $candidates[$best])); + $hinted = count($close) === 1 ? $best : (in_array($hint, $close, true) ? $hint : null); + $chosen = $hinted ?? (in_array(CorePlugin::Standards[0], $close, true) ? CorePlugin::Standards[0] : $best); + return [ + $chosen, + $chosen === 'perCs' && ($changed['psr12'] ?? PHP_INT_MAX) < $changed['perCs'] + ? StandardReason::Successor + : StandardReason::Fewest, + $hinted === null ? array_values(array_diff($close, [$chosen])) : [], + ]; + } + + + /** The name of the standard a reader knows it by: "PER Coding Style". */ + public function describe(): string + { + return implode(', ', array_map(fn(string $preset) => self::Names[$preset] ?? $preset, $this->presets)); + } + + + /** Why the standard was chosen, as the comment beside `use` says it; nothing for one given. */ + public function describeReason(): string + { + return match ($this->reason) { + StandardReason::Given => '', + StandardReason::Formatter => Formatter::Pint->value . ' formats the project in this style', + StandardReason::Successor => $this->describe() . ', the successor of PSR-12, under which a fix changes fewer files', + StandardReason::Fewest => $this->describe() . ', of the standards the one a fix changes the fewest files of your code under', + }; + } + + + /** The value of the decision as the configuration writes it: `1`, `'$a + $b'`, `[apart, closingApart]`. */ + public function formatValue(string $path, mixed $value): string + { + return $this->decisions[$path]->decision->accept($value)->toWrittenText(); + } + + + /** Whether the value is the one the standard gives the decision. */ + public function isStandard(string $path, mixed $value): bool + { + $standard = $this->decisions[$path] ?? null; + return $standard !== null && $standard->decision->accept($value)->toData() === $standard->value->toData(); + } + + + /** + * What the standards of the core give the decision, the values a count is measured under besides its own. + * @return list + */ + public static function listStandardValues(string $path): array + { + static $standards; + $standards ??= array_map( + fn(string $name) => (new RunnerFactory)->resolve(new Config(use: [$name]), sys_get_temp_dir())->resolvedConfig->decisions, + CorePlugin::Standards, + ); + return array_values(array_map(fn(array $decisions) => ($decisions[$path] ?? null)?->value->toData(), $standards)); + } +} diff --git a/src/Measuring/Verification.php b/src/Measuring/Verification.php new file mode 100644 index 0000000000..cf4b94b90e --- /dev/null +++ b/src/Measuring/Verification.php @@ -0,0 +1,50 @@ +changedFiles = $result->changedFiles; + $foundation = array_filter($result->fixable, Budget::isFoundation(...), ARRAY_FILTER_USE_KEY); + $this->foundationFiles = count(array_replace([], ...array_values($foundation))); + $files = $findings = []; + foreach ($result->left as $byFile) { + $findings[] = array_sum($byFile); + $files += $byFile; + } + + $this->leftFindings = array_sum($findings); + $this->leftFiles = count($files); + } +} diff --git a/src/Measuring/enums.php b/src/Measuring/enums.php new file mode 100644 index 0000000000..1ec6970e22 --- /dev/null +++ b/src/Measuring/enums.php @@ -0,0 +1,131 @@ +b()' + doubleColon: 'Foo::bar()' + logicalNot: ! $x + reference: keep + spread: keep + namedArgument: keep + attribute: keep + alternativeSyntax: keep + body: keep + import: keep + arrowFunction: 'fn ($x) => $x' + padding: + array: forbidden + list: forbidden + offset: forbidden + semicolon: + after: 'for ($i = 0; $i < 5; $i++)' + +naming: + class: keep + method: keep + constant: keep + enumCase: keep + +casing: + magicConstant: uppercase + hexDigit: uppercase + function: declared # `strlen()`, not `StrLen()` + +qualification: + staticInFinalClass: required # `self::` in a final class + class: + otherNamespace: imported + currentNamespace: bare + phpdoc: imported # `@param Request $request`, not `@param \Illuminate\Http\Request $request` + +imports: + grouping: + class: separated + function: separated + constant: separated + order: + kinds: [constant, class, function] + names: alphabetical + unused: forbidden + aliasEqualToName: forbidden + +classes: + members: + order: keep + grouping: + traitUse: keep + protectedInEnum: keep + visibility: + constant: keep + emptyParentheses: + instantiation: forbidden # `new Foo` + attribute: keep + instantiationWrapping: keep + +functions: + trailingBareReturn: forbidden + arrowFunction: + emptyBody: keep + unreachableParameterDefault: forbidden + +types: + nullPosition: keep + +controlFlow: + switch: + fallThroughComment: keep + +expressions: + parenthesesAfterConstruct: forbidden # `return $x;`, not `return ($x);` + assignment: + increment: postfix + doubleNegation: forbidden + comparison: + notEquals: '!=' + yoda: forbidden + +literals: + arraySyntax: + list: short + quotes: single + +comments: + singleline: // + +phpdoc: + empty: forbidden + forbiddenAnnotations: [@access, @package, @subpackage] + aboveAttributes: keep + stars: aligned + blankLines: trimmed + types: + builtin: canonical + annotation: + nativeTypeOnly: forbidden + +hygiene: + aliasFunctions: [core, imap, pg] + +upgrading: + php: + implicitNullable: forbidden diff --git a/src/Presets/symfony.neon b/src/Presets/symfony.neon new file mode 100644 index 0000000000..c69b5b6059 --- /dev/null +++ b/src/Presets/symfony.neon @@ -0,0 +1,151 @@ +# Symfony Coding Standards +# +# The Symfony Coding Standards as the @Symfony rule set of PHP CS Fixer defines them, without most of its rules about +# phpDoc, of which it takes only an empty doc comment, the trimming and the canonical types: PER Coding Style, which the +# set builds on, with the casing, the imports, the blank lines and the whitespace Symfony adds, and without the rules +# that would break a construct over lines, which Symfony leaves to the author. + +use: perCs + +file: + lineLength: none + longLines: keep + +indentation: + binaryOperator: keep + chain: keep + heredoc: keep # heredoc_indentation belongs to another set + +braces: + emptyBody: # not `{}` as in PER Coding Style + class: expanded + function: expanded + closure: expanded + propertyHook: expanded + anonymousClass: keep + singlelineClosure: keep + bareStatementGroup: forbidden # no `{ ... }` that nothing opens + alternativeSyntax: forbidden # no `if: ... endif;` + +# a range `0-1` leaves the choice to the author, `1+` is one or more +blankLines: + afterDeclare: keep # @Symfony leaves the line below a declare + aroundDeclarations: 0-1 # around one among statements + aroundMethods: 1 + aroundInterfaceMethods: 1 + betweenTraitUses: keep + afterTraitUses: keep + beforeStatement: {return: 1+} # a `return` set apart from the code above it + belowComment: + inlineVar: 0 # `/** @var Order $order */` right above its statement + +multiline: + # no fixer of the set breaks a call, a signature, a chain, a condition, a ternary, an operator or an array + items: keep + edges: keep + operatorPosition: # operator_linebreak moves only a boolean operator + operators: keep + logical: lineStart + condition: lineStart + trailingComma: + argument: optional # may stay or not where spread, never on one line + closureUse: keep + attributePosition: + parameter: keep + +spacing: + arrowFunction: 'fn ($x) => $x' + alignment: + comma: optional # aligning spaces and tabs may stay + binaryOperator: forbidden # no extra spaces around an operator + semicolon: + after: 'for ($i = 0; $i < 5; $i++)' + logicalNot: '!$x' # never `! $x` + concatenation: '$a.$b' + objectOperator: '$a->b()' + padding: + array: forbidden # `[1, 2]`, `[$a, $b] = $x` + list: forbidden + offset: forbidden # `$a[0]` + anonymousClass: 'new class($a)' + comment: + marker: '// text' # also `#` and `/* */` + afterCode: '$a; // text' # at least one space + +casing: + magicConstant: uppercase + class: declared # `DateTime`, not `datetime` + function: declared # `strlen()`, not `StrLen()` + method: declared # `__toString()`, other methods with `typeAnalysis` + hexDigit: keep # integer_literal_case left to the project + +# global_namespace_import imports nothing: a class is written with the backslash, a function and a constant too where +# they are qualified, and a bare one stays bare +qualification: + class: + global: backslashed # `\DateTime` + function: + global: [backslashed, bare] # `\ucfirst()` or `ucfirst()`, never imported + optimized: [backslashed, bare] + constant: + global: [backslashed, bare] + optimized: [backslashed, bare] + +imports: + order: + names: alphabetical # classes, functions, constants, each alphabetically + grouping: separated # `use A; use B;`, not `use A, B;` or `use Acme\{A, B};` + unused: forbidden + aliasEqualToName: forbidden # `use Foo;`, not `use Foo as Foo;` + +classes: + visibility: + publicWithSet: required # `public private(set)`, as modifier_keywords writes it + emptyParentheses: + attribute: keep # `#[Foo()]` stays, as in @Symfony + untypedPropertyNullDefault: forbidden # `public $a;`, not `public $a = null;` + instantiationWrapping: keep # @Symfony leaves the parentheses + protectedInFinalClass: forbidden # `private` in a final class with no parent and no trait + +functions: + trailingBareReturn: forbidden # no `return;` closing a function + +controlFlow: + elseAfterReturn: forbidden # the `else` after a `return` goes, its body following the `if` + +expressions: + comparison: + notEquals: '!=' # not `<>` + yoda: required # `null === $a` + assignment: + addingOne: forbidden # `++$i`, not `$i += 1` + increment: prefix # `++$i;` + doubleNegation: forbidden # `(bool) $a`, not `!!$a` + parenthesesAfterConstruct: forbidden # `return $x;`, not `return ($x);` + +literals: + quotes: single + heredocWithoutInterpolation: keep # heredoc_to_nowdoc belongs to another set + concatenatedLiterals: keep # no_useless_concat_operator left to the project + +comments: + singleline: '//' # not `#` + empty: forbidden + +phpdoc: + aboveAttributes: keep # @Symfony leaves the order alone, unlike PER Coding Style + blankLines: trimmed + empty: forbidden + types: + builtin: canonical # `int`, not `integer` + duplicate: forbidden # each type of a union once + nullPosition: last + +mistakes: + continueInSwitch: forbidden # a `continue` only leaving a `switch`, reported + +upgrading: + php: + dollarBraceInterpolation: forbidden # `{$name}`, not `${name}` + implicitNullable: forbidden # `?int $x = null`, not `int $x = null` + backticks: forbidden # `shell_exec()` diff --git a/src/Profile.php b/src/Profile.php index 5fe4543f6b..1f7f50f328 100644 --- a/src/Profile.php +++ b/src/Profile.php @@ -39,9 +39,9 @@ public ?string $nameResolution; /** - * a comment the project already writes to say a line is meant as it is: pattern => the rules or decisions it - * silences where it stands, as `dresscode:ignore` does, and the example of such a comment, `` standing for - * the reason (`// @ `) + * a comment the project already writes to say a line is meant as it is: pattern => the rules, decisions or names + * of other tools it silences where it stands, as `dresscode:ignore` does, and the example of such a comment, + * `` standing for the reason (`// @ `) * @var array, example: ?string}> */ public array $suppressionComments; diff --git a/src/Reporters/CollectingReporter.php b/src/Reporters/CollectingReporter.php new file mode 100644 index 0000000000..d163e2d099 --- /dev/null +++ b/src/Reporters/CollectingReporter.php @@ -0,0 +1,41 @@ +> the path => what the code of the file violates, a clean file left out */ + public private(set) array $violations = []; + + + public function start(RunInfo $run): void + { + } + + + public function reportFile(FileResult $result): void + { + if ($result->violations !== []) { + $this->violations[$result->path] = $result->violations; + } + } + + + public function finish(RunResult $result): void + { + } +} diff --git a/src/Reporters/ConsoleReporter.php b/src/Reporters/ConsoleReporter.php index 5124c4f74a..656e280db4 100644 --- a/src/Reporters/ConsoleReporter.php +++ b/src/Reporters/ConsoleReporter.php @@ -60,6 +60,8 @@ public function __construct( private readonly string $cwd = '', /** @var ?\Closure(string): ?string the path of a decision => the address of its page */ private readonly ?\Closure $findDecisionUrl = null, + /** the command whose `--ask-risky` decides the risky fixes left */ + private readonly string $command = 'fix', ) { } @@ -307,14 +309,14 @@ private function formatRefused(Risk $risk, RunResult $result): string $text = match ($risk) { Risk::NameUncertain => "$wait, a name may reach a function of the namespace: " . ($this->namespacesListed ? 'set `nameResolution: certain`' - : 'list what the namespaces declare under `namespaces`, or set `nameResolution: certain` if they declare nothing'), + : 'run `dresscode init`, which lists what the namespaces declare, or set `nameResolution: certain` if they declare nothing'), Risk::TypeUnknown => match ($this->typeAnalysis) { TypeAnalysisStatus::Available => "$wait, the type is unknown: set `typeAnalysis: phpstan`", - TypeAnalysisStatus::Enabled => "$wait, not even the types tell: check $them with `fix --ask-risky`", - TypeAnalysisStatus::Unavailable => "$wait, the type is unknown: check $them with `fix --ask-risky`", + TypeAnalysisStatus::Enabled => "$wait, not even the types tell: check $them with `$this->command --ask-risky`", + TypeAnalysisStatus::Unavailable => "$wait, the type is unknown: check $them with `$this->command --ask-risky`", }, Risk::BehaviorChanges => ($count === 1 ? '1 risky fix changes' : "$count risky fixes change") - . " what the code does: decide $them with `fix --ask-risky`, or accept $decisions in `fixRisky`", + . " what the code does: decide $them with `$this->command --ask-risky`, or accept $decisions in `fixRisky`", }; $docs = match ($risk) { Risk::TypeUnknown => 'types#enable', @@ -340,7 +342,7 @@ private function formatVerdict(RunResult $result): string $failures = $result->countFailures(); $affected = count(array_filter( $result->files, - fn(FileSummary $f) => $f->violations || $f->syntaxError !== null || $f->failure !== null, + fn(FileSummary $f) => $f->violations->count() > 0 || $f->syntaxError !== null || $f->failure !== null, )); $derived = $result->countDerived(Severity::Error); diff --git a/src/Reporters/JsonReporter.php b/src/Reporters/JsonReporter.php index 13c05a106c..133105445a 100644 --- a/src/Reporters/JsonReporter.php +++ b/src/Reporters/JsonReporter.php @@ -104,6 +104,12 @@ private static function formatViolation(Violation $violation, string $text): arr 'because' => $violation->because, 'fingerprint' => $violation->fingerprint, 'derivedFrom' => $violation->derivedFrom, + 'source' => $violation->source === null ? null : [ + 'package' => $violation->source->package, + 'library' => $violation->source->library, + 'since' => $violation->source->since, + 'from' => $violation->source->from, + ], ]; } diff --git a/src/RuleContext.php b/src/RuleContext.php index 9b6fbfd961..53a6a19872 100644 --- a/src/RuleContext.php +++ b/src/RuleContext.php @@ -73,6 +73,8 @@ public function __construct( * false comes back whatever the run allows, and a mutation after it breaks the contract. * `$decision` is the path of the decision the occurrence violates, which only a rule of one requirement leaves * out; one the run does not report, being `keep` or narrowed away, records nothing and returns false. + * `$source` is the section of the upgrading data of a package whose entry the occurrence is of, as a rule reading + * a map gets it from `Values::getEntryOrigin()`. */ public function report( Node|Token $at, @@ -85,6 +87,7 @@ public function report( bool $byLine = false, bool $fixable = true, ?string $because = null, + ?Config\Layer $source = null, ): bool { if ($trivia === null && $lineInTrivia !== 0) { @@ -113,6 +116,7 @@ public function report( fixable: $fixable, because: $because, lineInTrivia: $lineInTrivia, + source: $source, ); } @@ -188,6 +192,7 @@ private function record( bool $fixable = true, ?string $because = null, int $lineInTrivia = 0, + ?Config\Layer $source = null, ): bool { if ($this->policy->strict && $because !== null && $risk === null) { @@ -245,6 +250,7 @@ private function record( construct: $construct, lineInTrivia: $lineInTrivia, span: $this->spans?->find($at, $trivia, $lineInTrivia, $gap, $follows), + source: $source, ); return $fixable && !$refused && !$left; } diff --git a/src/Rules/Functions/InvokeCallNotationRule.php b/src/Rules/Functions/InvokeCallNotationRule.php new file mode 100644 index 0000000000..3439e7cf7f --- /dev/null +++ b/src/Rules/Functions/InvokeCallNotationRule.php @@ -0,0 +1,121 @@ +__invoke($a)`; an + * object held in a property gets parentheses, `($this->handler)($a)`, so that PHP does not read a method call. A call + * in a nullsafe chain stays: calling the object is no link of the chain, so a null would be called instead of skipped. + * + * Required, it is called through `__invoke()`, which a search for the usages of the method finds. Only an expression + * the types show to be objects with `__invoke()` and no Closure is rewritten, another one may be a callable of any + * kind; `$f(...)` stays, since it makes a Closure of the object. + */ +#[RuleInfo(Stage::Structure, analyses: [Types::class])] +final class InvokeCallNotationRule extends NodeRule +{ + private const Path = 'hygiene.__invokeCall'; + + private bool $required = false; + + + public static function getDecisions(): array + { + return [new Decision( + self::Path, + Domain::state('forbidden', 'required'), + '`$f->__invoke($x)` is `$f($x)`, or the other way round for an object', + )]; + } + + + public function configure(Values $values): void + { + $this->required = $values->find(self::Path)?->getWord() === 'required'; + } + + + public function getVisitedNodes(): array + { + return [MethodCallNode::class, FunctionCallNode::class]; + } + + + public function enter(Node|Token $node, RuleContext $context): void + { + if ($node instanceof MethodCallNode && !$this->required) { + $this->callDirectly($node, $context); + } elseif ($node instanceof FunctionCallNode && $this->required) { + $this->callThroughInvoke($node, $context); + } + } + + + private function callDirectly(MethodCallNode $node, RuleContext $context): void + { + if ( + !$node->operator->is('->') + || !$node->name instanceof IdentifierNode + || !$node->name->equals('__invoke') + || $node->object->isInNullsafeChain() + || $node->object->getLastToken()->hasCommentUpTo($node->arguments->openParen) + || !$context->report($node->name, 'The invokable object must be called directly, not through `__invoke()`.') + ) { + return; + } + + $node->replaceWith((new Builder)->call($node->object, $node->arguments)); + } + + + private function callThroughInvoke(FunctionCallNode $node, RuleContext $context): void + { + $callee = $node->name; + if (!$callee instanceof ExpressionNode) { + return; + } + + $object = $callee instanceof ParenthesizedNode ? $callee->expression : $callee; + if ( + $node->arguments->isPartialApplication() + || ($types = $context->findAnalysis(Types::class)) === null + || !$this->isInvokableObject($object, $types) + || $this->hasCommentInTheWay($node, $callee, $object) + || !$context->report($callee, 'The invokable object must be called through `__invoke()`.') + ) { + return; + } + + $node->replaceWith((new Builder)->methodCall($object, '__invoke', $node->arguments)); + } + + + private function isInvokableObject(ExpressionNode $object, Types $types): bool + { + $classes = $types->findClasses($object); + return $classes !== [] + && $types->isOfType($object, 'object') === Tristate::Yes + && $types->isOfType($object, \Closure::class) === Tristate::No + && array_all($classes, fn(string $class) => $types->hasMember($class, MemberKind::Method, '__invoke')); + } + + + private function hasCommentInTheWay(FunctionCallNode $node, ExpressionNode $callee, ExpressionNode $object): bool + { + return $callee->getLastToken()->hasCommentUpTo($node->arguments->openParen) + || $callee->getFirstToken()->hasCommentUpTo($object->getFirstToken()) + || $object->getLastToken()->hasCommentUpTo($callee->getLastToken()); + } +} diff --git a/src/Rules/Functions/NoAliasFunctionsRule.php b/src/Rules/Functions/NoAliasFunctionsRule.php index 424cff780c..4e06eac517 100644 --- a/src/Rules/Functions/NoAliasFunctionsRule.php +++ b/src/Rules/Functions/NoAliasFunctionsRule.php @@ -173,4 +173,12 @@ public function enter(Node|Token $node, RuleContext $context): void $node->name->text = CodeWriter::spellFunction($canonical, $node->name, $context); } + + + /** Whether the function is an alias of the other one in every call, so that writing the other one changes nothing. */ + public static function isAlias(string $alias, string $function): bool + { + $canonical = array_merge(...array_values(self::Sets))[strtolower($alias)] ?? null; + return $canonical !== null && $canonical !== 'time' && $canonical === strtolower(ltrim($function, '\\')); + } } diff --git a/src/Rules/Functions/NoExplicitInvokeCallsRule.php b/src/Rules/Functions/NoExplicitInvokeCallsRule.php deleted file mode 100644 index f7ba368cd0..0000000000 --- a/src/Rules/Functions/NoExplicitInvokeCallsRule.php +++ /dev/null @@ -1,52 +0,0 @@ -__invoke($a)`; an object held in - * a property gets parentheses, `($this->handler)($a)`, so that PHP does not read a method call. A call in a nullsafe - * chain stays: calling the object is no link of the chain, so a null would be called instead of skipped. - */ -#[RuleInfo(Stage::Structure)] -final class NoExplicitInvokeCallsRule extends NodeRule -{ - public static function getDecisions(): array - { - return [new Decision('hygiene.__invokeCall', Domain::state('forbidden'), '`$f->__invoke($x)` is `$f($x)`')]; - } - - - public function getVisitedNodes(): array - { - return [MethodCallNode::class]; - } - - - public function enter(Node|Token $node, RuleContext $context): void - { - if ( - !$node instanceof MethodCallNode - || !$node->operator->is('->') - || !$node->name instanceof IdentifierNode - || !$node->name->equals('__invoke') - || $node->object->isInNullsafeChain() - || $node->object->getLastToken()->hasCommentUpTo($node->arguments->openParen) - || !$context->report($node->name, 'The invokable object must be called directly, not through `__invoke()`.') - ) { - return; - } - - $node->replaceWith((new Builder)->call($node->object, $node->arguments)); - } -} diff --git a/src/Rules/Upgrading/AnnotationMap.php b/src/Rules/Upgrading/AnnotationMap.php index b5a3a01071..854817cf2a 100644 --- a/src/Rules/Upgrading/AnnotationMap.php +++ b/src/Rules/Upgrading/AnnotationMap.php @@ -8,6 +8,7 @@ namespace DressCode\Rules\Upgrading; use DressCode\Analyses\PhpDoc; +use DressCode\Config\Layer; use DressCode\{QualifiedNames, RuleContext, Values}; use DressCode\Upgrading\MemberMapGrammar; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; @@ -52,7 +53,10 @@ private function __construct( private array $attributes, /** @var array lowercased class of an attribute, fully qualified => the attribute written instead */ private array $replacedAttributes, - /** @var array lowercased namespace of annotations => the namespace of the attributes written instead */ + /** + * @var array lowercased namespace of annotations => the namespace of the attributes + * written instead and the section of the upgrading data that laid it + */ private array $namespaces, /** @var array lowercased classes withdrawn from the map, which no namespace reaches */ private array $withdrawn = [], @@ -64,11 +68,12 @@ public static function fromValues(Values $values): self { $attributes = $replacedAttributes = $namespaces = []; foreach ($values->readMap(MemberMapGrammar::AttributeForAnnotation) as $key => $code) { + $origin = $values->getEntryOrigin(MemberMapGrammar::AttributeForAnnotation, (string) $key); if (str_ends_with((string) $key, '*')) { if (($namespace = AttributeTarget::findNamespace($code)) !== null) { - $namespaces[strtolower(trim((string) $key, '\\*'))] = $namespace; + $namespaces[strtolower(trim((string) $key, '\\*'))] = [$namespace, $origin]; } - } elseif (($target = AttributeTarget::fromCode($code)) === null) { + } elseif (($target = AttributeTarget::fromCode($code, $origin)) === null) { continue; } elseif (str_contains((string) $key, '\\')) { $replacedAttributes[strtolower(ltrim((string) $key, '\\'))] = $target; @@ -110,9 +115,12 @@ public function findAttributeOfClass(?string $class): ?AttributeTarget } $namespace = QualifiedNames::extractNamespace($class); - $target = $this->namespaces[strtolower($namespace)] ?? null; - return $this->replacedAttributes[strtolower($class)] - ?? ($target === null ? null : new AttributeTarget($target . substr($class, strlen($namespace)), fromNamespace: true)); + [$target, $origin] = $this->namespaces[strtolower($namespace)] ?? [null, null]; + return $this->replacedAttributes[strtolower($class)] ?? ($target === null ? null : new AttributeTarget( + $target . substr($class, strlen($namespace)), + fromNamespace: true, + origin: $origin, + )); } diff --git a/src/Rules/Upgrading/AttributeForAnnotationRule.php b/src/Rules/Upgrading/AttributeForAnnotationRule.php index ba8cc1550a..914a438fa8 100644 --- a/src/Rules/Upgrading/AttributeForAnnotationRule.php +++ b/src/Rules/Upgrading/AttributeForAnnotationRule.php @@ -120,9 +120,9 @@ public function enter(Node|Token $node, RuleContext $context): void $message = "Annotation `$child->name` is replaced by the attribute " . Violation::formatCode("#[$attribute->class$attribute->arguments]"); if ($refusal !== null) { - $context->report($node, $message . $refusal . '.', trivia: $docComment, fixable: false); + $context->report($node, $message . $refusal . '.', trivia: $docComment, fixable: false, source: $attribute->origin); $kept[] = $child; - } elseif ($context->report($node, $message . '.', trivia: $docComment)) { + } elseif ($context->report($node, $message . '.', trivia: $docComment, source: $attribute->origin)) { [$arguments] = $this->renderArguments( $child, $attribute, @@ -206,6 +206,7 @@ private function enterAttribute(AttributeNode $node, RuleContext $context): void . ($refusal ?? '') . '.', fixable: $refusal === null, + source: $attribute->origin, )) { // the arguments go over as they are, the library reading them the same way $code = CodeWriter::writeClass($attribute->class, $node, $context) . ($arguments === '' ? $attribute->arguments : $arguments); diff --git a/src/Rules/Upgrading/AttributeForMemberEntry.php b/src/Rules/Upgrading/AttributeForMemberEntry.php index 85fbc67f28..51972422bc 100644 --- a/src/Rules/Upgrading/AttributeForMemberEntry.php +++ b/src/Rules/Upgrading/AttributeForMemberEntry.php @@ -7,6 +7,7 @@ namespace DressCode\Rules\Upgrading; +use DressCode\Config\Layer; use DressCode\Upgrading\MemberMapGrammar; use DressCode\{Values, Violation}; use PhpSyntax\{Builder, ParseException}; @@ -34,6 +35,8 @@ public function __construct( public string $arguments, /** @var ?array{mixed} the default of the property the entry stands for alone, wrapped so that null is one too */ public ?array $literal = null, + /** the section of the upgrading data of a package that laid the entry */ + public ?Layer $origin = null, ) { } @@ -47,7 +50,8 @@ public static function fromValues(Values $values): array { $entries = []; foreach ($values->readMap(MemberMapGrammar::AttributeForMember) as $key => $value) { - $entries[] = self::fromEntry((string) $key, $value); + $origin = $values->getEntryOrigin(MemberMapGrammar::AttributeForMember, (string) $key); + $entries[] = self::fromEntry((string) $key, $value, $origin); } usort($entries, fn(self $a, self $b) => ($b->literal !== null) <=> ($a->literal !== null)); @@ -59,7 +63,7 @@ public static function fromValues(Values $values): array * The entry of the map, its key the member or the interface and its value the attribute. * @throws \InvalidArgumentException saying what of it is not written as the map takes it */ - public static function fromEntry(string $key, string $value): self + public static function fromEntry(string $key, string $value, ?Layer $origin = null): self { if (!preg_match('~^' . self::ClassPattern . '(\(.*\))?$~Ds', trim($value), $attribute)) { throw new \InvalidArgumentException('The attribute ' . Violation::formatCode($value) . ' for ' . Violation::formatCode($key) . ' is not written as `Class` or `Class(arguments)`.'); @@ -88,6 +92,7 @@ public static function fromEntry(string $key, string $value): self $attribute[1], $arguments, isset($m[3]) ? [self::readLiteral($m[3], $key)] : null, + $origin, ), (bool) preg_match('~^' . self::ClassPattern . '::(\w+)\(\)$~D', $key, $m) => new self( AttributeForMemberKind::Method, @@ -95,6 +100,7 @@ public static function fromEntry(string $key, string $value): self $m[2], $attribute[1], $arguments, + origin: $origin, ), (bool) preg_match('~^' . self::ClassPattern . '$~D', $key, $m) => new self( AttributeForMemberKind::Interface, @@ -102,6 +108,7 @@ public static function fromEntry(string $key, string $value): self '', $attribute[1], $arguments, + origin: $origin, ), default => throw new \InvalidArgumentException('The member ' . Violation::formatCode($key) . " is not written as `Class::\$name`, `'Class::\$name = literal'`, `Class::method()` or `Interface`."), }; diff --git a/src/Rules/Upgrading/AttributeForMemberRule.php b/src/Rules/Upgrading/AttributeForMemberRule.php index e50d76239d..64c5e46b59 100644 --- a/src/Rules/Upgrading/AttributeForMemberRule.php +++ b/src/Rules/Upgrading/AttributeForMemberRule.php @@ -202,7 +202,15 @@ private function write(ClassNode $class, array $found, RuleContext $context): vo $planned[strtolower($attribute)] = [$attribute, [...($planned[strtolower($attribute)][1] ?? []), ...$arguments]]; } - $reports[] = [$at, "$described is replaced by the attribute `#[$attribute]`", $refusal, $member, $attribute, $arguments]; + $reports[] = [ + $at, + "$described is replaced by the attribute `#[$attribute]`", + $refusal, + $member, + $attribute, + $arguments, + $entry->origin, + ]; } // the members of one attribute together must give every argument it requires @@ -217,11 +225,11 @@ private function write(ClassNode $class, array $found, RuleContext $context): vo } $attributes = $removed = []; - foreach ($reports as [$at, $message, $refusal, $member, $attribute, $arguments]) { + foreach ($reports as [$at, $message, $refusal, $member, $attribute, $arguments, $source]) { $refusal ??= $missing[strtolower($attribute)] ?? null; if ($refusal !== null) { - $context->report($at, $message . $refusal . '.', fixable: false); - } elseif ($context->report($at, $message . '.')) { + $context->report($at, $message . $refusal . '.', fixable: false, source: $source); + } elseif ($context->report($at, $message . '.', source: $source)) { $attributes[strtolower($attribute)] = [$attribute, [...($attributes[strtolower($attribute)][1] ?? []), ...$arguments]]; $removed[] = $member; } diff --git a/src/Rules/Upgrading/AttributeTarget.php b/src/Rules/Upgrading/AttributeTarget.php index 8afafad4f2..6adda97fce 100644 --- a/src/Rules/Upgrading/AttributeTarget.php +++ b/src/Rules/Upgrading/AttributeTarget.php @@ -7,6 +7,8 @@ namespace DressCode\Rules\Upgrading; +use DressCode\Config\Layer; + /** * What attributeForAnnotation writes instead of an annotation or an attribute. @@ -26,14 +28,16 @@ public function __construct( public string $arguments = '', /** a namespace of the map gave the class, which may therefore not exist */ public bool $fromNamespace = false, + /** the section of the upgrading data of a package that laid the entry */ + public ?Layer $origin = null, ) { } /** The attribute written as `Class` or `Class(arguments)`, its class fully qualified; null for code of another shape. */ - public static function fromCode(string $code): ?self + public static function fromCode(string $code, ?Layer $origin = null): ?self { - return preg_match(self::Pattern, $code, $m) ? new self($m[1], $m[2] ?? '') : null; + return preg_match(self::Pattern, $code, $m) ? new self($m[1], $m[2] ?? '', origin: $origin) : null; } diff --git a/src/Rules/Upgrading/CallTemplate.php b/src/Rules/Upgrading/CallTemplate.php index f83e170073..562486ac97 100644 --- a/src/Rules/Upgrading/CallTemplate.php +++ b/src/Rules/Upgrading/CallTemplate.php @@ -7,6 +7,7 @@ namespace DressCode\Rules\Upgrading; +use DressCode\Config\Layer; use DressCode\{RuleContext, Violation}; use DressCode\Rules\NodeHelpers; use DressCode\Upgrading\{ @@ -309,13 +310,14 @@ public function describe(string $subject): string * Takes the call doing nothing away with its statement, where neither what it is made on nor its arguments would do * anything when evaluated; a call whose value is used, one that is the body of a construct without braces and one * holding a comment are only reported. The subject is the called function or member as the message names it, the - * decision the one it is reported under, and the uncertainty why the name of a function may call another one - * (`GlobalCalls::findUncertainty()`). + * decision the one it is reported under with the section of the upgrading data the entry came with, and the + * uncertainty why the name of a function may call another one (`GlobalCalls::findUncertainty()`). */ public function removeCall( FunctionCallNode|MethodCallNode|StaticMethodCallNode $call, string $subject, string $decision, + ?Layer $source, RuleContext $context, ?string $uncertainty = null, ): void @@ -340,6 +342,7 @@ public function removeCall( $call->name, $this->describe($subject) . "$refusal.", decision: $decision, + source: $source, fixable: $refusal === null, risk: NodeHelpers::findRisk($behaviorChange, $uncertainty), because: $behaviorChange ?? $uncertainty, diff --git a/src/Rules/Upgrading/ClassReplacement.php b/src/Rules/Upgrading/ClassReplacement.php index 0d5e593a53..361ae950dd 100644 --- a/src/Rules/Upgrading/ClassReplacement.php +++ b/src/Rules/Upgrading/ClassReplacement.php @@ -9,6 +9,7 @@ use DressCode\Analyses\{MemberKind, PhpDoc, Types}; use DressCode\{CodeWriter, QualifiedNames, Risk, RuleContext, Tristate}; +use DressCode\Config\Layer; use DressCode\Rules\Namespaces\NameReferences; use DressCode\Rules\NodeHelpers; use PHPStan\PhpDocParser\Lexer\Lexer; @@ -40,10 +41,10 @@ final class ClassReplacement /** * Reports every reference of a class the closure has something to say about, and rewrites the ones it names * a replacement for and the report allows. - * @param \Closure(string): ?array{0: string, 1: ?string, 2?: ?string} $find given a fully qualified class, the message, - * the class written instead, null for none, and why writing it may change what the code does, which makes the fix - * risky; null for a class that is left alone - * @param \Closure(string, ?array{string, MemberKind}, Token, list): (array{0: string, 1: ?string, 2?: ?string}|false|null) $findInDocs + * @param \Closure(string): ?array{0: string, 1: ?string, 2?: ?string, 3?: ?Layer} $find given a fully qualified class, + * the message, the class written instead, null for none, why writing it may change what the code does, which makes + * the fix risky, and the section of the upgrading data that names the class; null for a class that is left alone + * @param \Closure(string, ?array{string, MemberKind}, Token, list): (array{0: string, 1: ?string, 2?: ?string, 3?: ?Layer}|false|null) $findInDocs * the same as `$find` for a class a doc comment names, with the member it names, the token holding the comment and * the lowercased classes beside it in a union type, false for one that goes on naming the class and keeps its import * @param ?\Closure(NameNode): bool $keeps whether the reference goes on naming the class, which nothing is said @@ -77,6 +78,7 @@ public static function rewriteReferences( $result[1], $result[2] ?? null, $result[1] !== null && in_array(strtolower($result[1]), $union, true), + $result[3] ?? null, ]; } } @@ -91,7 +93,7 @@ public static function rewriteReferences( if ($item instanceof UseItemNode) { $found = $item->symbolKind === SymbolKind::ClassLike ? $find($item->fullName) : null; if ($found !== null) { - $imports[] = [$item, $item->fullName, $found[0], $found[1], $found[2] ?? null]; + $imports[] = [$item, $item->fullName, $found[0], $found[1], $found[2] ?? null, $found[3] ?? null]; } } elseif ($name->symbolKind === SymbolKind::ClassLike && $name->isReference()) { @@ -108,7 +110,7 @@ public static function rewriteReferences( } elseif (($keeps !== null && $keeps($name)) || self::keepsAlive($name, $found[1], $resolver)) { $kept[strtolower($class)] = true; } else { - $references[] = [$name, $found[0], $found[1], $found[2] ?? null, $refusal]; + $references[] = [$name, $found[0], $found[1], $found[2] ?? null, $refusal, $found[3] ?? null]; if ($refusal !== null) { $kept[strtolower($class)] = true; // the reference left as it is needs its import } @@ -118,10 +120,10 @@ public static function rewriteReferences( // an annotation read as an attribute resolves through the import until it is one $kept += $annotations?->findAnnotatedClasses($scope, $context) ?? []; - foreach ($imports as [$item, $class, $message, $new, $because]) { + foreach ($imports as [$item, $class, $message, $new, $because, $source]) { if (isset($kept[strtolower($class)])) { continue; // the short name below goes on naming the class the import brings - } elseif (self::report($context, $item->name, $message, $new, $because) && $new !== null) { + } elseif (self::report($context, $item->name, $message, $new, $because, $source) && $new !== null) { $local = self::replaceImport($item, $new, $context); if ($local !== null) { self::shortenFullyQualified($scope, $new, $local); @@ -129,14 +131,14 @@ public static function rewriteReferences( } } - foreach ($references as [$name, $message, $new, $because, $refusal]) { + foreach ($references as [$name, $message, $new, $because, $refusal, $source]) { $inList = self::findList($name); $list = $inList[0] ?? null; $named = $list === null || $new === null ? null : $lists[spl_object_id($list)] ?? []; if ($refusal !== null) { - $context->report($name, $message . $refusal . '.', fixable: false); + $context->report($name, $message . $refusal . '.', fixable: false, source: $source); - } elseif (!self::report($context, $name, $message, $new, $because) || $new === null) { + } elseif (!self::report($context, $name, $message, $new, $because, $source) || $new === null) { continue; } elseif ($inList !== null && isset($named[strtolower($new)])) { @@ -220,17 +222,18 @@ private static function findDocMember(array $tokens, int $index): ?array * Reports every name of a doc comment, and writes the class that replaces it where the report allows, token by * token, so that the rest of the comment stays as it is written. * @param list $tokens - * @param list $found per name the index of its token, the message, the class - * written instead, why writing it is risky and whether the union type it stands in names that class already + * @param list $found per name the index of its token, the message, + * the class written instead, why writing it is risky, whether the union type it stands in names that class already + * and the section of the upgrading data that names the class */ private static function replaceInDocComment(Token $token, Trivia $trivia, array $tokens, array $found, RuleContext $context): void { $changed = false; $unfixable = []; - foreach ($found as [$index, $message, $new, $because, $named]) { + foreach ($found as [$index, $message, $new, $because, $named, $source]) { if ($new === null || $token->parent === null) { - $unfixable[] = $message; // reported once the comment is written, nothing changing after them - } elseif (!self::report($context, $token, $message, $new, $because, $trivia)) { + $unfixable[] = [$message, $source]; // reported once the comment is written, nothing changing after them + } elseif (!self::report($context, $token, $message, $new, $because, $source, $trivia)) { continue; } elseif ($named) { foreach (self::findDocUnionSpan($tokens, $index) as $i) { @@ -252,8 +255,8 @@ private static function replaceInDocComment(Token $token, Trivia $trivia, array $token->replaceTrivia($trivia, $trivia->withText(implode('', array_column($tokens, Lexer::VALUE_OFFSET)))); } - foreach ($unfixable as $message) { - $context->report($token, $message . '.', trivia: $trivia, fixable: false); + foreach ($unfixable as [$message, $source]) { + $context->report($token, $message . '.', trivia: $trivia, fixable: false, source: $source); } } @@ -265,6 +268,7 @@ private static function report( string $message, ?string $new, ?string $because, + ?Layer $source, ?Trivia $trivia = null, ): bool { @@ -272,6 +276,7 @@ private static function report( $at, $message . '.', trivia: $trivia, + source: $source, risk: $because === null ? null : Risk::BehaviorChanges, fixable: $new !== null, because: $because, diff --git a/src/Rules/Upgrading/ForbiddenMembersRule.php b/src/Rules/Upgrading/ForbiddenMembersRule.php index 98dc997383..8c1e190837 100644 --- a/src/Rules/Upgrading/ForbiddenMembersRule.php +++ b/src/Rules/Upgrading/ForbiddenMembersRule.php @@ -9,7 +9,7 @@ use DressCode\Analyses\{MemberKind, Types}; use DressCode\{NodeRule, RuleContext, RuleInfo, Stage, Values}; -use DressCode\Upgrading\{MemberMap, MemberMapEntry, MemberMapGrammar, MemberPattern}; +use DressCode\Upgrading\{MemberMap, MemberMapEntry, MemberMapGrammar}; use PhpSyntax\Analyses\NameResolver; use PhpSyntax\{Builder, Node, Token}; use PhpSyntax\Nodes\{ClassLikeNode, IdentifierNode, ParameterNode}; @@ -49,21 +49,13 @@ final class ForbiddenMembersRule extends NodeRule { public const Map = MemberMapGrammar::ForbiddenMembers; - /** @var MemberMap the entries with the end of the message, and whether the data of a package laid it */ + /** @var MemberMap the entries with the end of the message */ private MemberMap $map; public function configure(Values $values): void { - $fromPackages = $values->findPackageEntries(self::Map); - $this->map = MemberMap::fromValues( - $values, - self::Map, - fn(?string $message, MemberPattern $pattern, string $key) => [ - $message === null ? '' : ": $message", - isset($fromPackages[$key]), - ], - ); + $this->map = MemberMap::fromValues($values, self::Map, fn(?string $message) => $message === null ? '' : ": $message"); } @@ -162,8 +154,9 @@ private function reportMember( }; $context->report( $node instanceof NewNode ? $node->class : $node->name, - "$described " . self::formatVerdict($entry->value, $types->knowsMember($pattern->class, $access->kind, $access->name)), + "$described " . self::formatVerdict($entry, $types->knowsMember($pattern->class, $access->kind, $access->name)), decision: $entry->decision, + source: $entry->origin, fixable: false, ); return true; @@ -211,8 +204,9 @@ private function enterMagic(PropertyFetchNode|ArrayAccessNode $node, RuleContext $node instanceof PropertyFetchNode ? $node->name : $node->openBracket, $entry->pattern->describe(MemberKind::Method) . ' ' - . self::formatVerdict($entry->value, $types->knowsMember($entry->pattern->class, MemberKind::Method, $method)), + . self::formatVerdict($entry, $types->knowsMember($entry->pattern->class, MemberKind::Method, $method)), decision: $entry->decision, + source: $entry->origin, fixable: false, ); } @@ -242,8 +236,9 @@ private function enterCallableValue(ArrayNode|StringNode $node, RuleContext $con $callable->literal, $entry->pattern->describe($access->kind) . ' ' - . self::formatVerdict($entry->value, $types->knowsMember($entry->pattern->class, $access->kind, $access->name)), + . self::formatVerdict($entry, $types->knowsMember($entry->pattern->class, $access->kind, $access->name)), decision: $entry->decision, + source: $entry->origin, fixable: false, ); } @@ -263,8 +258,9 @@ private function enterDeclaration(MethodNode $node, RuleContext $context): void $exists = $types->knowsMember($entry->pattern->class, $kind, $entry->pattern->name); $context->report( $node->name, - $entry->pattern->describe($kind) . ' ' . self::formatVerdict($entry->value, $exists), + $entry->pattern->describe($kind) . ' ' . self::formatVerdict($entry, $exists), decision: $entry->decision, + source: $entry->origin, fixable: false, ); } @@ -295,8 +291,9 @@ private function enterPropertyDeclaration(PropertyNode|ParameterNode $node, Rule $exists = $types->knowsMember($entry->pattern->class, $kind, $name); $context->report( $at, - $entry->pattern->describe($kind) . ' ' . self::formatVerdict($entry->value, $exists), + $entry->pattern->describe($kind) . ' ' . self::formatVerdict($entry, $exists), decision: $entry->decision, + source: $entry->origin, fixable: false, ); } @@ -307,10 +304,10 @@ private function enterPropertyDeclaration(PropertyNode|ParameterNode $node, Rule /** * What the entry says of the use, with the end of its message: a member the data of a package retired is deprecated * while the installed version still has it and removed once it has not, one of the project is forbidden. - * @param array{string, bool} $value + * @param MemberMapEntry $entry */ - private static function formatVerdict(array $value, bool $exists): string + private static function formatVerdict(MemberMapEntry $entry, bool $exists): string { - return 'is ' . Retirement::of($value[1], $exists)->value . $value[0] . '.'; + return 'is ' . Retirement::of($entry->origin !== null, $exists)->value . $entry->value . '.'; } } diff --git a/src/Rules/Upgrading/ForbiddenSymbolsRule.php b/src/Rules/Upgrading/ForbiddenSymbolsRule.php index 03bf05bbab..a228a82f61 100644 --- a/src/Rules/Upgrading/ForbiddenSymbolsRule.php +++ b/src/Rules/Upgrading/ForbiddenSymbolsRule.php @@ -8,6 +8,7 @@ namespace DressCode\Rules\Upgrading; use DressCode\Analyses\{PhpSymbols, Types}; +use DressCode\Config\Layer; use DressCode\{NodeRule, RuleContext, RuleInfo, Stage, Values}; use DressCode\Upgrading\MemberMapGrammar; use PhpSyntax\Analyses\NameResolver; @@ -45,14 +46,14 @@ final class ForbiddenSymbolsRule extends NodeRule private const Constructs = [EvalNode::class, ExitNode::class, PrintNode::class, EmptyNode::class, IssetNode::class, UnsetNode::class]; /** - * @var array lowercased class => the class as the map spells it, the end - * of the message, whether the data of a package laid it and the decision it is reported under + * @var array lowercased class => the class as the map spells it, the + * end of the message, the section of the upgrading data of a package that laid it and the decision it is reported under */ private array $classes = []; /** - * @var list regular expression, what to do instead, whether the data of a package - * laid it, the decision it is reported under + * @var list regular expression, what to do instead, the section of the + * upgrading data of a package that laid it, the decision it is reported under */ private array $functions = []; @@ -60,8 +61,8 @@ final class ForbiddenSymbolsRule extends NodeRule private array $withdrawnFunctions = []; /** - * @var list regular expression, what to do instead, whether the data of a package - * laid it, the decision it is reported under + * @var list regular expression, what to do instead, the section of the + * upgrading data of a package that laid it, the decision it is reported under */ private array $constants = []; @@ -72,12 +73,11 @@ final class ForbiddenSymbolsRule extends NodeRule public function configure(Values $values): void { $this->classes = []; - $fromPackages = $values->findPackageEntries(self::Classes); foreach ($values->readMap(self::Classes) as $class => $message) { $this->classes[strtolower(ltrim((string) $class, '\\'))] = [ ltrim((string) $class, '\\'), $message === null ? '' : ": $message", - isset($fromPackages[$class]), + $values->getEntryOrigin(self::Classes, (string) $class), $values->getEntryDecision(self::Classes, (string) $class), ]; } @@ -89,17 +89,16 @@ public function configure(Values $values): void /** * The patterns of a map of names with what to do instead, and the names withdrawn from it. - * @return array{list, array} + * @return array{list, array} */ private static function readPatterns(Values $values, string $path, bool $caseInsensitive): array { $patterns = []; - $fromPackages = $values->findPackageEntries($path); foreach ($values->readMap($path) as $pattern => $sentence) { $patterns[] = [ '~^' . str_replace('\*', '.*', preg_quote(ltrim((string) $pattern, '\\'), '~')) . '$~' . ($caseInsensitive ? 'i' : ''), $sentence, - isset($fromPackages[$pattern]), + $values->getEntryOrigin($path, (string) $pattern), $values->getEntryDecision($path, (string) $pattern), ]; } @@ -140,10 +139,10 @@ private function checkClass(NameNode $node, RuleContext $context): void $entry = $this->classes[strtolower($context->getAnalysis(NameResolver::class)->resolveClass($node))] ?? null; if ($entry !== null) { - [$class, $sentence, $fromPackage, $decision] = $entry; + [$class, $sentence, $source, $decision] = $entry; $types = $context->findAnalysis(Types::class); - $verdict = Retirement::of($fromPackage, $types === null ? null : $types->findClassName($class) !== null); - $context->report($node, "Class `$class` is $verdict->value$sentence.", decision: $decision, fixable: false); + $verdict = Retirement::of($source !== null, $types === null ? null : $types->findClassName($class) !== null); + $context->report($node, "Class `$class` is $verdict->value$sentence.", decision: $decision, fixable: false, source: $source); } } @@ -157,15 +156,21 @@ private function checkFunction(FunctionCallNode $node, RuleContext $context): vo $resolved = $context->getAnalysis(NameResolver::class)->resolveFunction($node->name); $entry = isset($this->withdrawnFunctions[strtolower($resolved)]) ? null : self::findEntry($this->functions, $resolved); if ($entry !== null) { - [$sentence, $fromPackage, $decision] = $entry; + [$sentence, $source, $decision] = $entry; $types = $context->findAnalysis(Types::class); $php = $context->getAnalysis(PhpSymbols::class); - $verdict = Retirement::of($fromPackage, match (true) { + $verdict = Retirement::of($source !== null, match (true) { $types !== null => $types->findFunctionName($resolved) !== null, $php->isBuiltinFunction($resolved) => $php->isBuiltinFunction($resolved, $context->phpVersion), default => null, }); - $context->report($node->name, "Function `$resolved()` is $verdict->value$sentence.", decision: $decision, fixable: false); + $context->report( + $node->name, + "Function `$resolved()` is $verdict->value$sentence.", + decision: $decision, + fixable: false, + source: $source, + ); } } @@ -180,9 +185,15 @@ private function checkConstruct(Node $node, RuleContext $context): void $name = strtolower($keyword->text); $entry = isset($this->withdrawnFunctions[$name]) ? null : self::findEntry($this->functions, $name); if ($entry !== null) { - [$sentence, $fromPackage, $decision] = $entry; - $verdict = Retirement::of($fromPackage, exists: true); - $context->report($keyword, "Function `$name()` is $verdict->value$sentence.", decision: $decision, fixable: false); + [$sentence, $source, $decision] = $entry; + $verdict = Retirement::of($source !== null, exists: true); + $context->report( + $keyword, + "Function `$name()` is $verdict->value$sentence.", + decision: $decision, + fixable: false, + source: $source, + ); } } @@ -196,29 +207,36 @@ private function checkConstant(NameNode $node, RuleContext $context): void $resolved = $context->getAnalysis(NameResolver::class)->resolveConstant($node); $entry = isset($this->withdrawnConstants[$resolved]) ? null : self::findEntry($this->constants, $resolved); if ($entry !== null) { - [$sentence, $fromPackage, $decision] = $entry; + [$sentence, $source, $decision] = $entry; $types = $context->findAnalysis(Types::class); - $verdict = Retirement::of($fromPackage, match (true) { + $verdict = Retirement::of($source !== null, match (true) { $types !== null => $types->knowsConstant($resolved), $context->getAnalysis(PhpSymbols::class)->isBuiltinConstant($resolved) => true, default => null, }); - $context->report($node, "Constant `$resolved` is $verdict->value$sentence.", decision: $decision, fixable: false); + $context->report( + $node, + "Constant `$resolved` is $verdict->value$sentence.", + decision: $decision, + fixable: false, + source: $source, + ); } } /** * The first pattern the name matches, as the end of its message, empty where its entry says nothing to do instead, - * whether the data of a package laid it and the decision it is reported under; null where none matches. - * @param list $patterns - * @return ?array{string, bool, string} + * the section of the upgrading data of a package that laid it and the decision it is reported under; null where none + * matches. + * @param list $patterns + * @return ?array{string, ?Layer, string} */ private static function findEntry(array $patterns, string $name): ?array { - foreach ($patterns as [$pattern, $sentence, $fromPackage, $decision]) { + foreach ($patterns as [$pattern, $sentence, $source, $decision]) { if (preg_match($pattern, $name)) { - return [$sentence === null ? '' : ": $sentence", $fromPackage, $decision]; + return [$sentence === null ? '' : ": $sentence", $source, $decision]; } } diff --git a/src/Rules/Upgrading/ReplacedCallsRule.php b/src/Rules/Upgrading/ReplacedCallsRule.php index c2749eef6d..ac320c2d87 100644 --- a/src/Rules/Upgrading/ReplacedCallsRule.php +++ b/src/Rules/Upgrading/ReplacedCallsRule.php @@ -166,12 +166,17 @@ private function enterCall(MethodCallNode|StaticMethodCallNode|NewNode $node, Ru return; } - [$pattern, $template, $decision] = [$found->entry->pattern, $found->entry->value, $found->entry->decision]; + [$pattern, $template, $decision, $source] = [ + $found->entry->pattern, + $found->entry->value, + $found->entry->decision, + $found->entry->origin, + ]; [$bindings, $access] = [$found->bindings, $found->access]; assert($bindings !== null); if ($template->removesCall()) { assert(!$node instanceof NewNode); // only a method is removed - $template->removeCall($node, $pattern->describeAccess($access, $node), $decision, $context); + $template->removeCall($node, $pattern->describeAccess($access, $node), $decision, $source, $context); return; } @@ -208,7 +213,7 @@ private function enterCall(MethodCallNode|StaticMethodCallNode|NewNode $node, Ru return; } - if ($rewrite->report($node instanceof NewNode ? $node->class : $node->name, $message, $decision, $context)) { + if ($rewrite->report($node instanceof NewNode ? $node->class : $node->name, $message, $decision, $source, $context)) { $node->replaceWithExpression($rewrite->write($node, $context)); } } @@ -248,7 +253,7 @@ private function enterCallable(MethodCallNode|StaticMethodCallNode $node, RuleCo }; $message = self::describeCall($node, $access, $pattern, $template); - if ($rewrite->report($node->name, $message, $entry->decision, $context)) { + if ($rewrite->report($node->name, $message, $entry->decision, $entry->origin, $context)) { $node->replaceWithExpression((new Builder)->expression('fn() => $value', value: $rewrite->write($node, $context))); } } @@ -305,6 +310,7 @@ private function reportUnbound(MethodCallNode|StaticMethodCallNode|NewNode $node $node instanceof NewNode ? $node->class : $node->name, self::describeCall($node, $access, $entry->pattern, $entry->value) . ", but $reason.", decision: $entry->decision, + source: $entry->origin, fixable: false, ); } @@ -343,6 +349,7 @@ private function enterCallableValue(ArrayNode|StringNode $node, RuleContext $con $entry->value->describe($entry->pattern->describe($access->kind)) . ', but no expression stands for a callable value.', decision: $entry->decision, + source: $entry->origin, fixable: false, ); } @@ -403,7 +410,7 @@ private function enterAccess(PropertyFetchNode|StaticPropertyFetchNode|ArrayAcce return; } - [$message, $decision, $template, $bindings, $arguments] = $found; + [$message, $entry, $template, $bindings, $arguments] = $found; $rewrite = match (true) { $use === 'written' => new Rewrite(null, ', but it is written to, which a call cannot be'), $template === null => new Rewrite(null, ', but nothing says what a read of it becomes'), @@ -420,7 +427,13 @@ private function enterAccess(PropertyFetchNode|StaticPropertyFetchNode|ArrayAcce } $rewrite = self::fitPlace($node, $rewrite); - if ($rewrite->report($node instanceof ArrayAccessNode ? $node->openBracket : $node->name, $message, $decision, $context)) { + if ($rewrite->report( + $node instanceof ArrayAccessNode ? $node->openBracket : $node->name, + $message, + $entry->decision, + $entry->origin, + $context, + )) { $node->replaceWithExpression($rewrite->write($node, $context)); } } @@ -436,7 +449,7 @@ private function enterAssignment(AssignmentNode $node, RuleContext $context): vo return; } - [$message, $decision, $template, $bindings, $arguments] = $found; + [$message, $entry, $template, $bindings, $arguments] = $found; $rewrite = match (true) { $template === null => new Rewrite(null, ', but nothing says what an assignment to it becomes'), !$node->parent instanceof ExpressionStatementNode => new Rewrite(null, ', but the value of the assignment is used'), @@ -447,7 +460,13 @@ private function enterAssignment(AssignmentNode $node, RuleContext $context): vo static: $target instanceof StaticPropertyFetchNode, ), }; - if ($rewrite->report($target instanceof ArrayAccessNode ? $target->openBracket : $target->name, $message, $decision, $context)) { + if ($rewrite->report( + $target instanceof ArrayAccessNode ? $target->openBracket : $target->name, + $message, + $entry->decision, + $entry->origin, + $context, + )) { $node->replaceWithExpression($rewrite->write($node, $context)); } } @@ -463,7 +482,7 @@ private function enterIssetOrUnset(IssetNode|UnsetNode $node, RuleContext $conte continue; } - [$message, $decision, $template, $bindings, $arguments] = $found; + [$message, $entry, $template, $bindings, $arguments] = $found; $receiver = self::getReceiver($variable); $rewrite = match (true) { $template === null => new Rewrite( @@ -494,7 +513,7 @@ private function enterIssetOrUnset(IssetNode|UnsetNode $node, RuleContext $conte } $at = $variable instanceof PropertyFetchNode ? $variable->name : $variable->openBracket; - if (!$rewrite->report($at, $message, $decision, $context)) { + if (!$rewrite->report($at, $message, $entry->decision, $entry->origin, $context)) { continue; } elseif ($node instanceof IssetNode) { $node->replaceWithExpression($rewrite->write($node, $context)); @@ -553,6 +572,7 @@ private function enterAttribute(AttributeNode $node, RuleContext $context): void $node->name, $template->describe($found->entry->pattern->describe(MemberKind::Constructor)), $found->entry->decision, + $found->entry->origin, $context, )) { assert($new instanceof NewNode); @@ -574,6 +594,7 @@ private function enterDeclaration(MethodNode $node, RuleContext $context): void $entry->value->describe($entry->pattern->describe($kind)) . ', but a declaration is nothing an expression could stand for.', decision: $entry->decision, + source: $entry->origin, fixable: false, ); } @@ -582,12 +603,12 @@ private function enterDeclaration(MethodNode $node, RuleContext $context): void /** * What the map says of a property or an offset used the given way: the property of a key, else the magic method - * PHP calls for it, with the name or the key and the values as its arguments. The message, the decision it is - * reported under, the template or null where the key has none for that use, and what the template is instantiated - * with; null where the map says nothing. + * PHP calls for it, with the name or the key and the values as its arguments. The message, the entry deciding it, + * the template or null where the key has none for that use, and what the template is instantiated with; null where + * the map says nothing. * @param 'get'|'set'|'isset'|'unset' $use * @param list $values what the magic method gets besides the name or the key - * @return ?array{string, string, ?CallTemplate, ArgumentBindings, ArgumentListNode} + * @return ?array{string, MemberMapEntry, ?CallTemplate, ArgumentBindings, ArgumentListNode} */ private function findTemplate( PropertyFetchNode|StaticPropertyFetchNode|ArrayAccessNode $node, @@ -632,7 +653,7 @@ private function findTemplate( $bound = $arguments->findArgument(null, 0); return [ ($template ?? $known->value)->describe($known->pattern->describe($access->kind)), - ($hooks[$use] ?? $known)->decision, + $hooks[$use] ?? $known, $template, new ArgumentBindings($bound === null ? [] : ['value' => $bound]), $arguments, @@ -667,7 +688,7 @@ function (MemberMapEntry $entry) use ($call, $types, &$bindings): bool { ? null : [ $entry->value->describe($entry->pattern->describe(MemberKind::Method)), - $entry->decision, + $entry, $entry->value, $bindings[spl_object_id($entry)], $call->arguments, diff --git a/src/Rules/Upgrading/ReplacedClassesRule.php b/src/Rules/Upgrading/ReplacedClassesRule.php index 7e37b2d31a..df50763419 100644 --- a/src/Rules/Upgrading/ReplacedClassesRule.php +++ b/src/Rules/Upgrading/ReplacedClassesRule.php @@ -9,6 +9,7 @@ use DressCode\Analyses\{MemberAccess, MemberKind, PhpDoc, Types}; use DressCode\{CodeWriter, NodeRule, RuleContext, RuleInfo, Stage, Values}; +use DressCode\Config\Layer; use DressCode\Upgrading\{MemberMap, MemberMapGrammar}; use PhpSyntax\Analyses\NameResolver; use PhpSyntax\{Node, Token}; @@ -40,7 +41,10 @@ final class ReplacedClassesRule extends NodeRule { public const Map = MemberMapGrammar::ReplacedClasses; - /** @var array lowercased replaced name => the name written instead, both fully qualified */ + /** + * @var array lowercased replaced name => the name written instead, both fully + * qualified, and the section of the upgrading data that laid it + */ private array $classes = []; /** @var MemberMap */ @@ -54,7 +58,7 @@ public function configure(Values $values): void $options = $values->readMap(self::Map); $this->classes = []; foreach ($options as $old => $new) { - $this->classes[strtolower(ltrim((string) $old, '\\'))] = ltrim($new, '\\'); + $this->classes[strtolower(ltrim((string) $old, '\\'))] = [ltrim($new, '\\'), $values->getEntryOrigin(self::Map, (string) $old)]; } $this->forbiddenMembers = MemberMap::fromValues($values, MemberMapGrammar::ForbiddenMembers); @@ -77,14 +81,16 @@ public function enter(Node|Token $node, RuleContext $context): void ) { $types = $context->findAnalysis(Types::class); $find = function (string $class) use ($types): ?array { - $new = $this->classes[strtolower($class)] ?? null; + [$new, $source] = $this->classes[strtolower($class)] ?? [null, null]; return match (true) { $new === null => null, $types !== null && $types->findClassName($new, traits: true) === null => [ "Class `$class` is replaced by `$new`, but class `$new` does not exist in the project", null, + null, + $source, ], - default => ["Class `$class` is replaced by `$new`", $new], + default => ["Class `$class` is replaced by `$new`", $new, null, $source], }; }; ClassReplacement::rewriteReferences( diff --git a/src/Rules/Upgrading/ReplacedFunctionCallsRule.php b/src/Rules/Upgrading/ReplacedFunctionCallsRule.php index b084d44fb1..0037ebab6d 100644 --- a/src/Rules/Upgrading/ReplacedFunctionCallsRule.php +++ b/src/Rules/Upgrading/ReplacedFunctionCallsRule.php @@ -8,6 +8,7 @@ namespace DressCode\Rules\Upgrading; use DressCode\{CodeWriter, GlobalCalls, NodeRule, QualifiedNames, RuleContext, RuleInfo, Stage, Values}; +use DressCode\Config\Layer; use DressCode\Rules\NodeHelpers; use DressCode\Upgrading\{FunctionPattern, MemberMapGrammar}; use PhpSyntax\Analyses\NameResolver; @@ -33,8 +34,8 @@ final class ReplacedFunctionCallsRule extends NodeRule { /** - * @var array> the function => its keys with the - * decision each is reported under, the most specific first + * @var array> the function => its keys + * with the decision each is reported under and the section of the upgrading data it came with, the most specific first */ private array $functions = []; @@ -52,6 +53,7 @@ public function configure(Values $values): void $pattern, CallTemplate::fromEntry($value, $pattern), $values->getEntryDecision(MemberMapGrammar::ReplacedCalls, (string) $key), + $values->getEntryOrigin(MemberMapGrammar::ReplacedCalls, (string) $key), ]; $this->shortNames[strtolower(QualifiedNames::stripNamespace($pattern->name))] = true; } @@ -90,7 +92,7 @@ public function enter(Node|Token $node, RuleContext $context): void return; } - foreach ($this->functions[$function] as [$pattern, $template, $decision]) { + foreach ($this->functions[$function] as [$pattern, $template, $decision, $source]) { $bindings = $pattern->bind($call->arguments); if ($bindings === null) { continue; @@ -99,7 +101,7 @@ public function enter(Node|Token $node, RuleContext $context): void $subject = "Function `$pattern->name()`"; $uncertainty = str_contains($function, '\\') ? null : GlobalCalls::findUncertainty($call, $context); if ($template->removesCall()) { - $template->removeCall($call, $subject, $decision, $context, $uncertainty); + $template->removeCall($call, $subject, $decision, $source, $context, $uncertainty); return; } @@ -109,6 +111,7 @@ public function enter(Node|Token $node, RuleContext $context): void $call->name, $template->describe($subject) . "$rewrite->refusal.", decision: $decision, + source: $source, fixable: $rewrite->expression !== null, risk: $rewrite->expression === null ? null : NodeHelpers::findRisk($rewrite->because, $uncertainty), because: $rewrite->expression === null ? null : $rewrite->because ?? $uncertainty, diff --git a/src/Rules/Upgrading/ReplacedFunctionsRule.php b/src/Rules/Upgrading/ReplacedFunctionsRule.php index 6665a8e529..f1c10e2db4 100644 --- a/src/Rules/Upgrading/ReplacedFunctionsRule.php +++ b/src/Rules/Upgrading/ReplacedFunctionsRule.php @@ -9,6 +9,7 @@ use DressCode\Analyses\{Parameter, PhpSignatures, PhpSymbols}; use DressCode\{CodeWriter, GlobalCalls, NodeRule, QualifiedNames, Risk, RuleContext, RuleInfo, Stage, Values}; +use DressCode\Config\Layer; use DressCode\Rules\NodeHelpers; use DressCode\Upgrading\MemberMapGrammar; use PhpSyntax\Analyses\NameResolver; @@ -49,8 +50,8 @@ final class ReplacedFunctionsRule extends NodeRule public const Map = MemberMapGrammar::ReplacedFunctions; /** - * @var array lowercased replaced name => the replaced and the replacing name - * as the project spells them + * @var array lowercased replaced name => the replaced and the + * replacing name as the project spells them, and the section of the upgrading data that laid it */ private array $functions = []; @@ -61,7 +62,11 @@ public function configure(Values $values): void $this->functions = []; foreach ($options as $old => $new) { // both as the project spells them, which is what its message quotes back at it - $this->functions[strtolower(ltrim((string) $old, '\\'))] = [ltrim((string) $old, '\\'), ltrim($new, '\\')]; + $this->functions[strtolower(ltrim((string) $old, '\\'))] = [ + ltrim((string) $old, '\\'), + ltrim($new, '\\'), + $values->getEntryOrigin(self::Map, (string) $old), + ]; } } @@ -81,7 +86,7 @@ public function enter(Node|Token $node, RuleContext $context): void return; } - [$old, $new] = $this->functions[$key]; + [$old, $new, $source] = $this->functions[$key]; $resolver = $context->getAnalysis(NameResolver::class); $namespace = $resolver->getNamespace($node); @@ -93,6 +98,7 @@ public function enter(Node|Token $node, RuleContext $context): void fixable: $refusal === null, risk: $uncertainty === null ? null : Risk::NameUncertain, because: $uncertainty, + source: $source, )) { return; } diff --git a/src/Rules/Upgrading/ReplacedMembersRule.php b/src/Rules/Upgrading/ReplacedMembersRule.php index b8ca0d8e45..dcc9848a50 100644 --- a/src/Rules/Upgrading/ReplacedMembersRule.php +++ b/src/Rules/Upgrading/ReplacedMembersRule.php @@ -139,6 +139,7 @@ public function enter(Node|Token $node, RuleContext $context): void $node->name, $pattern->describeAccess($access, $node) . ' is replaced by ' . $target->describe($access->kind, $pattern) . $refusal . '.', decision: $entry->decision, + source: $entry->origin, fixable: $refusal === null, risk: $risk === null ? null : Risk::BehaviorChanges, because: $risk, @@ -195,6 +196,7 @@ private function enterDeclaration(MethodNode $node, RuleContext $context): void $node->name, $pattern->describe($kind) . ' is replaced by ' . $target->describe($kind, $pattern) . ($refusal ?? '') . '.', decision: $entry->decision, + source: $entry->origin, fixable: $refusal === null, )) { $node->name->text = $target->name; @@ -232,6 +234,7 @@ private function enterCallable(ArrayNode|StringNode $node, RuleContext $context) $callable->literal, $pattern->describe($access->kind) . ' is replaced by ' . $target->describe($access->kind, $pattern) . $refusal . '.', decision: $entry->decision, + source: $entry->origin, fixable: $refusal === null, risk: $risk === null ? null : Risk::BehaviorChanges, because: $risk, diff --git a/src/Rules/Upgrading/Rewrite.php b/src/Rules/Upgrading/Rewrite.php index 0a6d22659d..17fe638b4b 100644 --- a/src/Rules/Upgrading/Rewrite.php +++ b/src/Rules/Upgrading/Rewrite.php @@ -8,6 +8,7 @@ namespace DressCode\Rules\Upgrading; use DressCode\{CodeWriter, Risk, RuleContext}; +use DressCode\Config\Layer; use PhpSyntax\Analyses\NameResolver; use PhpSyntax\{Node, SymbolKind, Token}; use PhpSyntax\Nodes\{ArgumentListNode, ArgumentNode, ExpressionNode, NameNode}; @@ -45,15 +46,17 @@ public function withRisk(string $because): self /** - * Reports the use under the decision with what the template made of it, and says whether it is to be rewritten. + * Reports the use under the decision, with the section of the upgrading data the entry came with, and with what + * the template made of it, and says whether it is to be rewritten. * @phpstan-assert-if-true !null $this->expression */ - public function report(Node|Token $at, string $message, string $decision, RuleContext $context): bool + public function report(Node|Token $at, string $message, string $decision, ?Layer $source, RuleContext $context): bool { return $context->report( $at, $message . $this->refusal . '.', decision: $decision, + source: $source, fixable: $this->expression !== null, risk: $this->because === null ? null : Risk::BehaviorChanges, because: $this->because, diff --git a/src/Rules/Upgrading/UnseenReferencesRule.php b/src/Rules/Upgrading/UnseenReferencesRule.php new file mode 100644 index 0000000000..6abdec334f --- /dev/null +++ b/src/Rules/Upgrading/UnseenReferencesRule.php @@ -0,0 +1,137 @@ + lowercased class => the class as the map spells it, what the map + * does with it, the section of the upgrading data that laid it + */ + private array $classes = []; + + + public static function getDecisions(): array + { + return [ + new Decision(self::ClassNameString, new Words([ + 'reported' => 'reported where it stands: the string `\'Acme\Old\'` naming a class the maps rename or forbid', + ]), 'A string holding the name of a class `replacedClasses` or `forbiddenClasses` names, which no rename of the class reaches'), + new Decision(self::DynamicAccess, new Words([ + 'reported' => 'reported where it stands: `$object->$method()`, `new $class`, `call_user_func($callback)`', + ]), 'A member, a class or a function reached by a name the code computes, which no map of upgrading data matches'), + ]; + } + + + public function configure(Values $values): void + { + $this->classes = []; + foreach ([self::Forbidden => 'forbidden', self::Replaced => 'replaced'] as $path => $verdict) { + foreach ($values->readMap($path) as $class => $value) { + $class = ltrim((string) $class, '\\'); + $this->classes[strtolower($class)] = [ + $class, + $verdict === 'replaced' ? 'replaced by `' . ltrim((string) $value, '\\') . '`' : 'forbidden', + $values->getEntryOrigin($path, $class), + ]; + } + } + } + + + public function getVisitedNodes(): array + { + return [ + ...$this->classes === [] ? [] : [StringNode::class], + MethodCallNode::class, + StaticMethodCallNode::class, + PropertyFetchNode::class, + NewNode::class, + FunctionCallNode::class, + ]; + } + + + public function enter(Node|Token $node, RuleContext $context): void + { + if ($node instanceof StringNode) { + $this->checkString($node, $context); + return; + } + + $subject = match (true) { + $node instanceof MethodCallNode && !$node->name instanceof IdentifierNode => 'A method called', + $node instanceof PropertyFetchNode && !$node->name instanceof IdentifierNode => 'A property read', + $node instanceof StaticMethodCallNode && (!$node->name instanceof IdentifierNode || !$node->class instanceof NameNode) + => 'A static method called', + $node instanceof NewNode && $node->class instanceof ExpressionNode => 'A class instantiated', + $node instanceof FunctionCallNode && (!$node->name instanceof NameNode || $this->callsComputedName($node, $context)) + => 'A function called', + default => null, + }; + if ($subject !== null) { + $context->report( + $node, + "$subject by a name the code computes, which no map of upgrading data sees.", + decision: self::DynamicAccess, + fixable: false, + ); + } + } + + + private function checkString(StringNode $node, RuleContext $context): void + { + [$class, $verdict, $source] = $this->classes[strtolower(ltrim($node->toValue(), '\\'))] ?? [null, null, null]; + if ($class !== null) { + $context->report( + $node, + "The string names class `$class`, which is $verdict; no rewrite reaches a string.", + decision: self::ClassNameString, + fixable: false, + source: $source, + ); + } + } + + + /** Whether the call is of a function calling what its first argument names, given as other than a literal. */ + private function callsComputedName(FunctionCallNode $node, RuleContext $context): bool + { + assert($node->name instanceof NameNode); + $function = strtolower($context->getAnalysis(NameResolver::class)->resolveFunction($node->name)); + $first = $node->arguments->findArgument(null, 0); + return in_array($function, self::CallingFunctions, true) && $first !== null && !$first->value instanceof StringNode; + } +} diff --git a/src/Testing/RuleTester.php b/src/Testing/RuleTester.php index 73c1e0b534..856491eb7b 100644 --- a/src/Testing/RuleTester.php +++ b/src/Testing/RuleTester.php @@ -351,7 +351,7 @@ private static function createInstance(string|\Closure $rule, string $file, stri if (($tree['upgrading']['php']['deprecatedApi'] ?? 'keep') !== 'keep') { // the upgrading data of PHP lie beneath, as a run lays them $data = PackageDiscovery::readPhpData((new ProjectPackages)->withPhp($phpTarget)) ?? throw new \LogicException; - array_unshift($layers, [$data->layer, $data->buildDecisions(fn(string $map) => "upgrading.api.$map")]); + array_unshift($layers, ...$data->buildDecisions(fn(string $map) => "upgrading.api.$map")); } $catalogue = Catalogue::fromRules(self::getRules($class, $layers)); diff --git a/src/Testing/UpgradingTester.php b/src/Testing/UpgradingTester.php index 4dff3e7bc1..8c6169a999 100644 --- a/src/Testing/UpgradingTester.php +++ b/src/Testing/UpgradingTester.php @@ -12,6 +12,7 @@ use DressCode\Domains\Map; use DressCode\Rules\Upgrading\MemberTarget; use DressCode\Upgrading\{MemberMapGrammar, MemberPattern, MemberPatternKind}; +use Nette\Neon\{Exception, Neon}; use Nette\Schema\ValidationException; use Nette\Utils\FileSystem; use function count, is_array, is_string, strlen; @@ -78,6 +79,51 @@ public static function collectProblems(string $file, string $root, array $packag } + /** + * Checks that `extra.dresscode.packages` of the package at the root says exactly which packages its upgrading files + * are about, by which `upgrade` offers it to a project lacking it: none of them left out, and none listed that no + * file is about. + * @return list + */ + public static function collectPackageProblems(string $root): array + { + $composer = ComposerJson::read("$root/composer.json") ?? []; + $name = is_string($composer['name'] ?? null) ? $composer['name'] : 'the root package'; + $extra = $composer['extra']['dresscode'] ?? null; + if (!is_array($extra)) { + return []; + } + + try { + $about = []; + foreach (PackageDiscovery::findUpgradingFiles($root, $extra, $name) as $file) { + $package = Neon::decodeFile("$root/$file")['package'] ?? null; + if (is_string($package)) { + $about[$package] = $file; + } + } + } catch (ConfigurationException|Exception $e) { + return [$e->getMessage()]; + } + + $listed = is_array($extra['packages'] ?? null) ? array_values(array_filter($extra['packages'], is_string(...))) : []; + $problems = []; + foreach ($about as $package => $file) { + if (!PackageDiscovery::isAbout($listed, $package)) { + $problems[] = "Package `$name`: `extra.dresscode.packages` in its `composer.json` does not list `$package`, which `$file` is about."; + } + } + + foreach ($listed as $package) { + if (!array_any(array_keys($about), fn(string $library) => PackageDiscovery::isAbout([$package], $library))) { + $problems[] = "Package `$name`: `extra.dresscode.packages` in its `composer.json` lists `$package`, which no upgrading file is about."; + } + } + + return $problems; + } + + /** * Runs code written for the old API of the libraries through the rules as the project fixes it: every section * the installed version of a library reaches applies, whatever the constraint of the project allows, and the diff --git a/src/Upgrade/BlindSpots.php b/src/Upgrade/BlindSpots.php new file mode 100644 index 0000000000..e489b63e01 --- /dev/null +++ b/src/Upgrade/BlindSpots.php @@ -0,0 +1,34 @@ + the extension of the files no rule reads that an upgrade may change too => their count */ + public array $otherFiles = [], + /** the files a generator wrote, as their heads say, which the estimate leaves out */ + public int $generatedFiles = 0, + ) { + } + + + public function isEmpty(): bool + { + return $this->classNameStrings === 0 && $this->dynamicAccesses === 0 && $this->otherFiles === [] && $this->generatedFiles === 0; + } +} diff --git a/src/Upgrade/Blocker.php b/src/Upgrade/Blocker.php new file mode 100644 index 0000000000..b67db95660 --- /dev/null +++ b/src/Upgrade/Blocker.php @@ -0,0 +1,25 @@ + */ + public array $packages = [], + /** why Composer gave no answer, null where it gave one */ + public ?string $unknown = null, + ) { + } + + + /** Whether Composer said that nothing of the lock refuses the target. */ + public function isClear(): bool + { + return $this->packages === [] && $this->unknown === null; + } +} diff --git a/src/Upgrade/Composer.php b/src/Upgrade/Composer.php new file mode 100644 index 0000000000..e8adb38b01 --- /dev/null +++ b/src/Upgrade/Composer.php @@ -0,0 +1,308 @@ +rootPath ?? $root, $packages->rootName); + } + + + /** Composer of a project that has a lock, which the questions about the installed packages need; null for one without. */ + public static function forLockOf(ProjectPackages $project): ?self + { + return $project->rootPath !== null && is_file("$project->rootPath/composer.lock") + ? new self($project->rootPath, $project->rootName) + : null; + } + + + /** The installed packages of the lock refusing the version of the target, as `composer why-not --locked` says. */ + public function findBlockers(Target $target): Blockers + { + if (!is_file("$this->root/composer.lock")) { + return new Blockers($target, unknown: 'the project has no `composer.lock` to ask'); + } + + try { + // why-not exits with 1 where something refuses the version + $output = $this->run(['why-not', '--locked', $target->package, $target->version], self::Timeout, accepted: 1); + } catch (ComposerException $e) { + return new Blockers($target, unknown: $e->getMessage()); + } + + return self::parseBlockers($target, $output, $this->rootName); + } + + + /** + * The packages the output of `composer why-not` lists as refusing the version, the project itself left out; unknown + * where the output is no answer of why-not, which exits with 1 as a Composer that failed does. + * @internal + */ + public static function parseBlockers(Target $target, string $output, ?string $rootName): Blockers + { + $blockers = []; + $answered = str_contains($output, 'There is no installed package depending on'); + foreach (preg_split('~\R~', $output) ?: [] as $line) { + if (!preg_match(self::BlockerPattern, trim($line), $m)) { + continue; + } + + $answered = true; + // the project and the target itself, whose versions refusing the PHP Composer runs on refuse no target + if ($m[1] !== $rootName && $m[1] !== $target->package) { + $blockers[] = new Blocker($m[1], $m[2], $m[3] === 'conflicts' ? "conflicts with $m[4] $m[5]" : $m[5]); + } + } + + return $answered + ? new Blockers($target, $blockers) + : new Blockers($target, unknown: 'Composer gave no answer: ' . (self::findLastLine($output) ?? 'no output')); + } + + + /** + * What raising the constraints moves the installed packages to, as the dry run of the update says, which needs the + * network. + * @param array $constraints package => constraint + */ + public function findMoves(array $constraints): Moves + { + try { + return new Moves(self::parseMoves($this->previewRequire($constraints))); + } catch (ComposerException $e) { + return new Moves(unknown: $e->getMessage()); + } + } + + + /** + * The packages the operations of an update move from one version to another. + * @param list $operations as `parseOperations()` reads them + * @return list + */ + public static function parseMoves(array $operations): array + { + $moves = []; + foreach ($operations as $operation) { + if (preg_match('~^- (?:Upgrading|Downgrading) (\S+) \((\S+) => (\S+)\)~', $operation, $m)) { + $moves[] = new Move($m[1], $m[2], $m[3]); + } + } + + return $moves; + } + + + /** + * What the raise would do to the installed packages, changing nothing: the operations of the update, none without + * `$update`. The dry run is of copies of `composer.json` and the lock beside them, `config.platform.php` raised where + * the raise raises it, so that it resolves what the raise will, the platform alone among it. + * @param array $constraints package => constraint + * @return list as `- Upgrading nette/utils (v3.2.10 => v4.0.11)` + * @throws ComposerException + */ + public function previewRequire(array $constraints, bool $update = true, ?string $platform = null): array + { + $data = json_decode((string) @file_get_contents("$this->root/composer.json"), associative: true); // @ none is told below + if (!is_array($data)) { + throw new ComposerException("The project has no `composer.json` Composer reads in `$this->root`."); + } elseif ($platform !== null) { + $data['config']['platform']['php'] = $platform; + } + + // beside the original, so that the vendor directory and the paths it names are what they are for it + $copy = "$this->root/.dresscode-preview-" . bin2hex(random_bytes(6)); + try { + FileSystem::write("$copy.json", json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + if (is_file("$this->root/composer.lock")) { + FileSystem::copy("$this->root/composer.lock", "$copy.lock"); + } + + $arguments = $constraints === [] + ? ['update', '--lock', '--dry-run'] + : self::buildRequire($constraints, $update, dryRun: true); + return self::parseOperations($this->run($arguments, self::UpdateTimeout, env: ['COMPOSER' => "$copy.json"])); + } finally { + @unlink("$copy.json"); // @ may not have been written + @unlink("$copy.lock"); // @ the project may have no lock + } + } + + + /** + * Writes the constraints into `composer.json` and updates the packages they move to the lowest versions they allow, + * which the code is written for and whose `@deprecated` the run reads, those they depend on changed as little as + * the update needs; without `$update` only `composer.json` changes, and `config.platform.php` is raised first where + * given. + * @param array $constraints package => constraint + * @throws ComposerException + */ + public function require(array $constraints, bool $update = true, ?string $platform = null): void + { + if ($platform !== null) { + $this->run(['config', 'platform.php', $platform], self::Timeout); + } + + if ($constraints !== []) { + $this->run(self::buildRequire($constraints, $update), self::UpdateTimeout); + } elseif ($update) { // the platform alone, which the lock is to say it was resolved for + $this->run(['update', '--lock'], self::UpdateTimeout); + } + } + + + /** + * What updating every package to the newest version the constraints allow would do, changing nothing. + * @return list as `- Upgrading nette/utils (v4.0.0 => v4.0.11)` + * @throws ComposerException + */ + public function previewUpdate(): array + { + return self::parseOperations($this->run(['update', '--dry-run'], self::UpdateTimeout)); + } + + + /** + * Updates every package to the newest version the constraints allow. + * @throws ComposerException + */ + public function update(): void + { + $this->run(['update'], self::UpdateTimeout); + } + + + /** + * Installs the packages the lock says. + * @throws ComposerException + */ + public function install(): void + { + $this->run(['install'], self::UpdateTimeout); + } + + + /** + * Requires the packages at the versions Composer chooses and installs them, the way `composer require` does. + * @param list $packages + * @throws ComposerException + */ + public function add(array $packages, bool $dev = false): void + { + $this->run(['require', ...$dev ? ['--dev'] : [], ...$packages], self::UpdateTimeout); + } + + + /** + * The operations on packages the output of an update lists, each once, though Composer writes it among the + * operations of the lock and again among those of the installation. + * @return list as `- Upgrading nette/utils (v3.2.10 => v4.0.11)` + */ + public static function parseOperations(string $output): array + { + $lines = array_map(trim(...), preg_split('~\R~', $output) ?: []); + return array_values(array_unique(preg_grep('~^- (Upgrading|Downgrading|Installing|Removing) ~', $lines) ?: [])); + } + + + /** + * The arguments of `composer require` writing the constraints, with the update or without it. + * @param array $constraints + * @return list + */ + public static function buildRequire(array $constraints, bool $update, bool $dryRun = false): array + { + return [ + 'require', + ...array_map(fn(string $package, string $constraint) => "$package:$constraint", array_keys($constraints), $constraints), + ...$update ? ['-W', '--prefer-lowest', '--minimal-changes'] : ['--no-update'], + ...$dryRun ? ['--dry-run'] : [], + ]; + } + + + /** + * What Composer wrote on both streams. + * @param list $arguments + * @param array $env + * @throws ComposerException where it does not end in time or exits with a code above the one accepted + */ + private function run(array $arguments, int $timeout, int $accepted = 0, array $env = []): string + { + $binary = getenv('COMPOSER_BINARY'); + $command = [...$binary ? [PHP_BINARY, $binary] : ['composer'], ...$arguments, '--no-ansi', '--no-interaction']; + [$exit, $output] = ProcessRunner::run($command, $this->root, $timeout, env: $env); + if ($exit === null || $exit > $accepted) { + $log = $this->keepLog($command, $timeout, $exit, $output); + throw new ComposerException(($exit === null + ? "Composer did not answer within $timeout s" + : 'Composer failed: ' . (self::findLastLine($output) ?? "exit code $exit")) + . ($log === null ? '' : "; its whole output is in `$log`")); + } + + return $output; + } + + + /** + * Writes the command, its exit and all it wrote into a file of the temporary directory, as the message keeps only + * the last line; null where the file cannot be written. + * @param list $command + */ + private function keepLog(array $command, int $timeout, ?int $exit, string $output): ?string + { + $file = sys_get_temp_dir() . '/dresscode-composer-' . date('Ymd-His') . '-' . substr(hash('xxh128', $output), 0, 8) . '.log'; + $head = 'command: ' . implode(' ', $command) . "\ndirectory: $this->root\n" + . ($exit === null ? "timeout: $timeout s, not answered\n" : "exit code: $exit\n"); + return @file_put_contents($file, "$head\n$output") === false ? null : FileSystem::platformSlashes($file); // @ the temporary directory may refuse it + } + + + private static function findLastLine(string $output): ?string + { + $lines = array_filter(array_map(trim(...), preg_split('~\R~', $output) ?: [])); + return $lines === [] ? null : end($lines); + } +} diff --git a/src/Upgrade/ComposerJournal.php b/src/Upgrade/ComposerJournal.php new file mode 100644 index 0000000000..4f39af9dab --- /dev/null +++ b/src/Upgrade/ComposerJournal.php @@ -0,0 +1,195 @@ +readIndex() !== null) { + return; + } + + $before = []; + foreach (self::Files as $name) { + $text = @file_get_contents("$root/$name"); // @ a file the project does not have + if ($text !== false) { + Helpers::replaceFile("$this->dir/$name", $text); + } + + $before[$name] = $text !== false; + } + + $this->writeIndex(['step' => $step, 'before' => $before, 'after' => self::hashFiles($root)]); + } + + + /** Keeps what the raise left, which a file must still be for `restore()` to write it back. */ + public function recordAfter(string $root): void + { + $index = $this->readIndex(); + if ($index !== null) { + $this->writeIndex(['after' => self::hashFiles($root)] + $index); + } + } + + + /** The step whose raise the journal goes back before; null where it keeps nothing. */ + public function findStep(): ?int + { + return $this->readIndex()['step'] ?? null; + } + + + /** + * The files the raise changed. + * @return list + */ + public function findChanged(): array + { + $index = $this->readIndex(); + return $index === null + ? [] + : array_values(array_filter( + self::Files, + fn(string $name) => $this->hashKept($name, $index['before'][$name] ?? false) !== ($index['after'][$name] ?? null), + )); + } + + + /** Whether files were written back with a lock beside them, whose packages are still to be installed. */ + public function isAwaitingInstall(): bool + { + return $this->readIndex()['awaitingInstall'] ?? false; + } + + + /** Says that the packages of the lock written back are installed. */ + public function markInstalled(): void + { + $index = $this->readIndex(); + if ($index !== null) { + $this->writeIndex(['awaitingInstall' => false] + $index); + } + } + + + /** + * Writes the files back as they were before the raise, but a file changed after the raise, which is left as it is + * and stays kept, so that it can be put back once resolved; a file written back is what the raise left from then on. + * A dry run only says which files it would write back and which it would leave. + * @return array{list, array} the files written back, those left => why + */ + public function restore(string $root, bool $dryRun = false): array + { + $index = $this->readIndex(); + if ($index === null) { + return [[], []]; + } + + $restored = $left = []; + foreach (self::Files as $name) { + $kept = $index['before'][$name] ?? false; + $current = self::hashFile("$root/$name"); + if ($current === $this->hashKept($name, $kept)) { + continue; + } elseif ($current !== ($index['after'][$name] ?? null)) { + $left[$name] = "The file changed after the raise of step {$index['step']}, so it is not written back."; + } elseif ($dryRun) { + $restored[] = $name; + } elseif ($kept) { + Helpers::replaceFile("$root/$name", FileSystem::read("$this->dir/$name")); + $restored[] = $name; + } else { + FileSystem::delete("$root/$name"); + $restored[] = $name; + } + } + + if (!$dryRun && $restored !== []) { + foreach ($restored as $name) { + $index['after'][$name] = self::hashFile("$root/$name"); + } + + $index['awaitingInstall'] = is_file("$root/composer.lock"); + $this->writeIndex($index); + } + + return [$restored, $left]; + } + + + public function forget(): void + { + FileSystem::delete($this->dir); + } + + + private function hashKept(string $name, bool $kept): ?string + { + return $kept ? self::hashFile("$this->dir/$name") : null; + } + + + /** @return array */ + private static function hashFiles(string $root): array + { + return array_combine(self::Files, array_map(fn(string $name) => self::hashFile("$root/$name"), self::Files)); + } + + + private static function hashFile(string $file): ?string + { + $text = @file_get_contents($file); // @ a file that does not exist has no hash + return $text === false ? null : hash('xxh128', $text); + } + + + /** @return ?array{step: int, before: array, after: array, awaitingInstall?: bool} */ + private function readIndex(): ?array + { + $index = json_decode((string) @file_get_contents("$this->dir/index.json"), associative: true); // @ no journal is kept + return is_array($index) && is_int($index['step'] ?? null) && is_array($index['before'] ?? null) && is_array($index['after'] ?? null) + ? [ + 'step' => $index['step'], + 'before' => array_map(fn($kept) => $kept === true, $index['before']), + 'after' => array_map(fn($hash) => is_string($hash) ? $hash : null, $index['after']), + 'awaitingInstall' => ($index['awaitingInstall'] ?? false) === true, + ] + : null; + } + + + /** @param array{step: int, before: array, after: array, awaitingInstall?: bool} $index */ + private function writeIndex(array $index): void + { + Helpers::replaceFile("$this->dir/index.json", json_encode($index, JSON_THROW_ON_ERROR)); + } +} diff --git a/src/Upgrade/ComposerStep.php b/src/Upgrade/ComposerStep.php new file mode 100644 index 0000000000..b60f7db5b8 --- /dev/null +++ b/src/Upgrade/ComposerStep.php @@ -0,0 +1,86 @@ + package of `require` => the constraint written */ + public array $require, + /** the packages updated too, not only `composer.json` */ + public bool $update = true, + /** the version `config.platform.php` is raised to beside the constraint of PHP, where the project sets it lower */ + public ?string $platform = null, + public bool $skip = false, + ) { + } + + + public function formatCommands(): array + { + $commands = $this->platform === null ? [] : ["composer config platform.php $this->platform"]; + if ($this->require !== []) { + $arguments = Composer::buildRequire($this->require, $this->update); + $commands[] = 'composer ' + . implode(' ', array_map(fn(string $argument) => str_contains($argument, ':') ? "\"$argument\"" : $argument, $arguments)); + } elseif ($this->update) { // the platform alone, which the lock is to say it was resolved for + $commands[] = 'composer update --lock'; + } + + return $commands; + } + + + /** + * Whether the project has the step done: `composer.json` requires every package of the step with a lower bound the + * step reaches, allowing the newest version the step names, `^8.4` and `~8.4.0` raising `^8.1` to `^8.4` and + * `^3.2 || ^4.0` widening `^3.2`, and for a step that updates, the versions installed and locked are ones the step + * allows, for PHP the platform the project sets, which reaches the one the step raises it to. + * @phpstan-impure + */ + public function isDoneIn(ProjectPackages $project): bool + { + $locked = $this->update ? $project->readLockedVersions() : null; + return ($this->platform === null || version_compare($project->platformPhp ?? '0', $this->platform, '>=')) + && array_all($this->require, function (string $constraint, string $package) use ($project, $locked): bool { + $required = $project->required[$package] ?? null; + $lowests = array_map( + fn(string $part) => Versions::findLowestVersion($part) ?? '0', + Versions::splitAlternatives($constraint), + ); + usort($lowests, fn(string $a, string $b) => version_compare($a, $b)); + // false for a package missing, null for nothing to compare, as the version of a branch without an alias + $installed = match (true) { + $package === PackageDiscovery::Php => $project->platformPhp, + !isset($project->installed[$package]) => false, + default => $project->installed[$package]['version'], + }; + $lock = match (true) { + $locked === null || $package === PackageDiscovery::Php => null, + !array_key_exists($package, $locked) => false, + default => $locked[$package], + }; + return $required !== null + && version_compare(Versions::findLowestVersion($required) ?? '0', $lowests[0] ?? '0', '>=') + && Versions::isSubset((string) end($lowests), $required) + && (!$this->update + || (($installed === null || ($installed !== false && Versions::isSubset($installed, $constraint))) + && ($lock === null || ($lock !== false && Versions::isSubset($lock, $constraint))))); + }); + } +} diff --git a/src/Upgrade/Constraints.php b/src/Upgrade/Constraints.php new file mode 100644 index 0000000000..77fc159a61 --- /dev/null +++ b/src/Upgrade/Constraints.php @@ -0,0 +1,153 @@ + $targets + * @return array package => constraint + */ + public function raise(array $targets): array + { + $require = []; + foreach ($targets as $target) { + $constraint = $this->project->required[$target->package] ?? null; + $lowest = $constraint === null ? null : Versions::findLowestVersion($constraint); + if ($lowest === null || version_compare($lowest, $target->version, '<')) { + $require[$target->package] = self::raiseConstraint(trim((string) $constraint), $target->version); + } + } + + return $require; + } + + + /** + * The constraint raised to the version in the shape the project writes it, its upper bounds kept: a part of `||` the + * version is past dropped, and the lower bound of a part allowing it raised, `>=8.4` for `>=8.1`, `^8.4` for a caret + * or a bare version, `>=8.4 <8.5` for `>=8.1 <8.5`; where the version is past every part, the last raised the same + * way, `~8.4.0` for `~8.1.0`, and a caret for one with an upper bound of its own. + */ + public static function raiseConstraint(string $constraint, string $version): string + { + $parts = Versions::splitAlternatives($constraint); + $raised = []; + foreach ($parts as $part) { + if (version_compare(Versions::findLowestVersion($part) ?? '0', $version, '>=')) { + $raised[] = $part; + } elseif (Versions::isSubset($version, $part)) { + $raised[] = self::rewriteLowerBound($part, $version); + } + } + + $last = end($parts); + return match (true) { + $raised !== [] => implode(' || ', $raised), + $last === false || self::findUpperBound($last) !== null => "^$version", + default => self::rewriteLowerBound($last, $version), + }; + } + + + /** The upper bound of its own the constraint raised to the version passes, `<8.4` of `>=8.1 <8.4` raised to 8.4. */ + public static function findPassedUpperBound(string $constraint, string $version): ?string + { + $parts = Versions::splitAlternatives($constraint); + $last = end($parts); + return $last === false || array_any($parts, fn(string $part) => Versions::isSubset($version, $part) + || version_compare(Versions::findLowestVersion($part) ?? '0', $version, '>=')) + ? null + : self::findUpperBound($last); + } + + + /** + * The part of a constraint with its lower bound replaced by the version, its upper bound kept: each operator with its + * version, `>= 8.1` as `>=8.1`, and a range of a hyphen with its end. + */ + private static function rewriteLowerBound(string $part, string $version): string + { + $part = (string) preg_replace('~(<=|>=|<|>|!=|==|=|\^|\~)\s+(?=v?\d)~', '$1', trim($part)); + if (preg_match(self::HyphenRange, $part, $m)) { + return "$version - $m[2]"; + } + + $tokens = array_map(fn(string $token) => match (true) { + str_starts_with($token, '<'), str_starts_with($token, '!=') => $token, + str_starts_with($token, '>') => ">=$version", + (bool) preg_match('~^\~\s*v?(\d+(?:\.\d+)*)$~', $token, $m) + => '~' . implode('.', array_pad(explode('.', $version), substr_count($m[1], '.') + 1, '0')), + default => "^$version", + }, preg_split('~\s*,\s*|\s+~', trim($part), flags: PREG_SPLIT_NO_EMPTY) ?: []); + return implode(str_contains($part, ',') ? ', ' : ' ', $tokens); + } + + + private static function findUpperBound(string $part): ?string + { + return match (true) { + (bool) preg_match(self::HyphenRange, trim($part), $m) => $m[2], + (bool) preg_match('~<=?\s*v?\d[\w.-]*~', $part, $m) => $m[0], + default => null, + }; + } + + + /** + * The constraints the targets widen, `^3.2 || ^4.0` for `^3.2`; nothing for a target they allow. + * @param list $targets + * @return array package => constraint + */ + public function widen(array $targets): array + { + $require = []; + foreach ($targets as $target) { + $constraint = $this->project->required[$target->package] ?? null; + if ($constraint !== null && !Versions::isSubset($target->version, $constraint)) { + $require[$target->package] = "$constraint || ^$target->version"; + } + } + + return $require; + } + + + /** + * The version of PHP `config.platform.php` is raised to, where `composer.json` sets it below the target, which + * Composer would otherwise resolve the packages for. + * @param list $targets + */ + public function raisePlatform(array $targets): ?string + { + $platform = $this->project->platformPhp; + $target = array_find($targets, fn(Target $target) => $target->package === PackageDiscovery::Php); + return $platform !== null && $target !== null && version_compare($platform, $target->version, '<') + ? $target->version + : null; + } +} diff --git a/src/Upgrade/DataPackageInstallation.php b/src/Upgrade/DataPackageInstallation.php new file mode 100644 index 0000000000..d477988bdd --- /dev/null +++ b/src/Upgrade/DataPackageInstallation.php @@ -0,0 +1,57 @@ + */ + public array $packages, + /** the directory of the `composer.json` they go into; null for an installation Composer cannot add to, a phar */ + public ?string $directory, + public bool $intoProject, + ) { + } + + + /** What the project needs installed; null where DressCode has every package of upgrading data it needs. */ + public static function find(string $root, string $cacheDir): ?self + { + $project = ProjectPackages::read($root); + $missing = PackageDiscovery::findMissingDataPackages($project, DataPackageCatalogue::load($cacheDir)); + if ($missing === []) { + return null; + } + + $intoProject = $project->has('dresscode/dresscode'); + return new self($missing, $intoProject ? $root : PackageDiscovery::findInstallationRoot(), $intoProject); + } + + + /** The command of Composer that installs them, as a user would type it. */ + public function formatCommand(): string + { + $command = ['composer', 'require', ...$this->intoProject ? ['--dev'] : [], "--working-dir=$this->directory", ...$this->packages]; + return implode(' ', array_map(fn(string $arg) => str_contains($arg, ' ') ? "\"$arg\"" : $arg, $command)); + } + + + /** @throws ComposerException */ + public function install(): void + { + new Composer((string) $this->directory)->add($this->packages, dev: $this->intoProject); + } +} diff --git a/src/Upgrade/DependencyStep.php b/src/Upgrade/DependencyStep.php new file mode 100644 index 0000000000..fbadb7b99a --- /dev/null +++ b/src/Upgrade/DependencyStep.php @@ -0,0 +1,22 @@ + + */ + function formatCommands(): array; +} diff --git a/src/Upgrade/Estimate.php b/src/Upgrade/Estimate.php new file mode 100644 index 0000000000..a10cc23ca6 --- /dev/null +++ b/src/Upgrade/Estimate.php @@ -0,0 +1,70 @@ + */ + public array $targets, + /** @var list those of the jump first, each phase by its count of places, the largest first */ + public array $topics, + /** the files checked */ + public int $files, + public BlindSpots $blindSpots, + /** @var list one for every target, where the project has a lock */ + public array $blockers, + /** @var array> a package of upgrading data DressCode lacks => the libraries of the project it is about */ + public array $missingData, + /** @var array library => the newest version its upgrading data know of */ + public array $knownVersions, + /** the run measured what the namespaces declare and resolved the names for certain, the configuration saying nothing */ + public bool $namesMeasured, + /** the run had the types of the code */ + public bool $types, + /** @var list what the user should be told about the run */ + public array $warnings, + /** what raising the constraints to the targets moves, null where Composer was not asked, offline or without a lock */ + public ?Moves $moves = null, + /** @var array the files the check could not process, whose places are not counted => why */ + public array $unprocessed = [], + /** @var list the paths within the configured ones it was narrowed to, relative to the root; none for all */ + public array $paths = [], + /** measured for the plan of a library, which widens its constraints and so moves no installed package */ + public bool $library = false, + ) { + } + + + /** Whether the estimate saw everything it was to: every file processed, the upgrading data there, Composer asked. */ + public function isComplete(): bool + { + return $this->unprocessed === [] + && $this->missingData === [] + && array_all($this->blockers, fn(Blockers $blockers) => $blockers->unknown === null); + } + + + /** + * The topics of the phase, of one set or of any. + * @return list + */ + public function findTopics(Phase $phase, ?string $set = null): array + { + return array_values(array_filter( + $this->topics, + fn(Topic $topic) => $topic->phase === $phase && ($set === null || $topic->set === $set), + )); + } +} diff --git a/src/Upgrade/Estimator.php b/src/Upgrade/Estimator.php new file mode 100644 index 0000000000..1390332c70 --- /dev/null +++ b/src/Upgrade/Estimator.php @@ -0,0 +1,377 @@ + + */ + public function proposeTargets(bool $phpOnly = false): array + { + $factory = new RunnerFactory; + $project = $factory->readProjectPackages($this->project->config, $this->project->root); + $known = PackageDiscovery::discover($project)->findKnownVersions(); + $php = [Target::php($known[PackageDiscovery::Php] ?? Config::DefaultPhpVersion)]; + $today = Versions::findLowestVersion($factory->getPhpTarget($this->project->config, $this->project->root)[0]); + if ($today !== null && version_compare($today, '8.0', '<')) { + unset($known[PackageDiscovery::Php]); + $php = []; + } + + $targets = []; + foreach ($phpOnly ? [] : $known as $library => $version) { + $lowest = $project->findVersion($library); + if ($lowest === null || version_compare($version, $lowest, '>')) { + $targets[] = new Target($library, $version); + } + } + + return $targets ?: $php; + } + + + /** + * @param list $targets + * @param list $paths the files and directories measured within the configured paths, those paths where empty + * @throws ConfigurationException + */ + public function estimate(array $targets, array $paths = []): Estimate + { + $versions = Target::collectVersions($targets); + $this->onProgress?->__invoke('looking for files'); + $discovered = $this->project->discoverPackages($versions); + $plugins = $discovered->findDataPlugins(); + // the project as it is today, narrowed to nothing, which tells what runs before the raise, and whose runner finds the files + $todayRun = new UpgradeCheck($this->project->createLayer($plugins, $this->types), [], self::Sets); + [$todayFactory, $today] = $todayRun->resolve($this->project->config, $this->project->root); + $runner = $todayFactory->createRunner($today, cache: false); + $configured = $this->project->config->paths; + $scope = $paths ? array_map($runner->files->relativize(...), $runner->files->narrowPaths($paths, $configured)) : $configured; + $found = $this->project->findUpgradeFiles($scope); + $files = $found->files; + + $this->onProgress?->__invoke('reading the declarations of the namespaces'); + // those of the whole scope, which a call within the paths named may reach + $names = $this->project->resolveNames($paths ? null : $files); + $warnings = $names->warning === null ? [] : [$names->warning]; + if (version_compare($today->resolvedConfig->phpVersion, '8.0', '<')) { + $warnings[] = 'Upgrading to PHP 8.0 is not covered; fixes of PHP 8 constructs stay off for 7.x.'; + } + + $run = new UpgradeCheck( + $this->project->createLayer( + $plugins, + $this->types, + $versions, + $names->declarations, + [self::Unseen => ['classNameString' => 'reported', 'dynamicAccess' => 'reported']], + ), + [...self::Fixes, self::Unseen, ...$discovered->findReachingPackages()], + self::Sets, + $this->project->configFile === null ? $this->project->config : null, + ); + [$factory, $resolution] = $run->resolve($this->project->config, $this->project->root); + $this->onProgress?->__invoke('checking ' . count($files) . ' files'); + $collected = new CollectingReporter; + $result = $factory->createRunner($resolution, cache: false) + ->run($files, false, $collected, $this->createWorkers?->__invoke($run, count($files))); + [$topics, $unseen] = self::collectTopics($collected->violations, $resolution, $today, $targets); + $packages = ProjectPackages::read($this->project->root); + $composer = Composer::forLockOf($packages); + + return new Estimate( + targets: $targets, + topics: $topics, + files: count($files), + blindSpots: new BlindSpots( + $unseen[UnseenReferencesRule::ClassNameString] ?? 0, + $unseen[UnseenReferencesRule::DynamicAccess] ?? 0, + $this->countOtherFiles($runner, $scope), + count($found->generated), + ), + blockers: $composer === null ? [] : $this->findBlockers($composer, $targets), + missingData: $this->findMissingData($packages), + knownVersions: $discovered->findKnownVersions(), + namesMeasured: $names->declarations !== null, + types: $resolution->resolvedConfig->typeAnalysis !== null, + warnings: [...array_keys($resolution->warnings), ...$warnings], + moves: $composer === null || $this->offline || $this->library ? null : $this->findMoves($composer, $packages, $targets), + unprocessed: self::collectUnprocessed($result), + paths: $paths ? $scope : [], + library: $this->library, + ); + } + + + /** + * The topics of the places the check found, the jump first and each phase by its count of places, and the places of + * what the upgrade cannot see by their decision. + * @param array> $violations the path => what the file violates + * @param list $targets + * @return array{list, array} + */ + private static function collectTopics(array $violations, ResolvedProject $resolution, ResolvedProject $today, array $targets): array + { + $sets = array_map(fn(string $set) => array_flip($resolution->expandName($set)->paths), array_combine(self::Sets, self::Sets)); + $topics = $unseen = []; + foreach ($violations as $path => $list) { + foreach ($list as $violation) { + if (str_starts_with($violation->decision, self::Unseen . '.')) { + $unseen[$violation->decision] = ($unseen[$violation->decision] ?? 0) + 1; + continue; + } + + $phase = self::findPhase($violation, $today); + $library = $violation->source->library ?? self::findLibrary($violation->decision, $resolution); + $name = $library ?? $violation->decision; + $key = "$name|$phase->value"; + $topics[$key] ??= [ + 'name' => $name, + 'set' => self::findSet($violation, $sets), + 'phase' => $phase, + // a decision of no library is made by the target, where there is only one + 'constraint' => self::findConstraint( + $library ?? (count($targets) === 1 ? $targets[0]->package : null), + $phase, + $targets, + $today, + ), + 'files' => [], + 'places' => array_fill_keys(array_column(Fixability::cases(), 'value'), 0), + ]; + $topics[$key]['files'][$path] = true; + $topics[$key]['places'][Fixability::of($violation)->value]++; + } + } + + $topics = array_map( + fn(array $topic) => new Topic( + $topic['name'], + $topic['set'], + $topic['phase'], + count($topic['files']), + $topic['places'], + $topic['constraint'], + ), + array_values($topics), + ); + usort($topics, fn(Topic $a, Topic $b) => [$b->phase === Phase::Jump, $b->countPlaces(), $a->name] + <=> [$a->phase === Phase::Jump, $a->countPlaces(), $b->name]); + return [$topics, $unseen]; + } + + + /** + * What the fixes of a topic of the library are right on: its constraint of today in the bridge, and in the jump the one + * the target raises it to, as the plan writes it. + * @param list $targets + */ + private static function findConstraint(?string $library, Phase $phase, array $targets, ResolvedProject $today): ?string + { + $required = $library === null ? null : $today->projectPackages->required[$library] ?? null; + $target = array_find($targets, fn(Target $target) => $target->package === $library); + return match ($phase) { + Phase::Bridge => $required, + Phase::Jump => $target === null ? null : Constraints::raiseConstraint(trim((string) $required), $target->version), + }; + } + + + /** + * The files the check could not process, by why. + * @return array + */ + private static function collectUnprocessed(RunResult $result): array + { + $failed = []; + foreach ($result->files as $summary) { + if ($summary->syntaxError !== null || $summary->failure !== null) { + $failed[$summary->path] = $summary->failure ?? "Syntax error: $summary->syntaxError"; + } + } + + return $failed; + } + + + /** + * The library whose upgrading data alone fill the map of the decision, which a place of a rule that does not say + * which entry it reports belongs to; null for a decision no map of such data fills. + */ + private static function findLibrary(string $decision, ResolvedProject $resolution): ?string + { + $values = $resolution->resolvedConfig->values; + $value = $values->has($decision) ? $values->get($decision) : null; + if (!$value?->domain instanceof Map || $value->isKept()) { + return null; + } + + $libraries = array_unique(array_map( + fn(string $key) => $values->getEntryOrigin($decision, $key)?->library, + array_keys($value->getEntries()), + )); + return count($libraries) === 1 ? reset($libraries) : null; + } + + + /** + * The phase of a place: an entry of upgrading data by the version its fix is right from against the lowest version the + * project allows today, any other place by whether its decision runs for the project as it is today, which one the + * data of a newer version turn on does not. + */ + private static function findPhase(Violation $violation, ResolvedProject $today): Phase + { + $source = $violation->source; + if ($source?->library !== null && $source->since !== null) { + $lowest = $today->projectPackages->findVersion($source->library); + return $lowest !== null && version_compare($lowest, $source->from ?? $source->since, '>=') ? Phase::Bridge : Phase::Jump; + } + + $decision = $today->resolvedConfig->decisions[$violation->decision] ?? null; + return $decision === null || ($decision->inactive !== null && $decision->inactive !== InactiveReason::OnlyOverride) + ? Phase::Jump + : Phase::Bridge; + } + + + /** + * The set of a place: the first whose decisions its decision is one of, and `deprecations`, which lays them, for an + * entry of upgrading data. + * @param array> $sets set => its decisions as keys + */ + private static function findSet(Violation $violation, array $sets): string + { + return $violation->source === null + ? array_find_key($sets, fn(array $paths) => isset($paths[$violation->decision])) ?? self::Sets[0] + : self::Sets[0]; + } + + + /** + * The files of the scope no rule reads that an upgrade may have to change as well, a template or a configuration, by + * their extension. + * @param list $scope + * @return array extension => count + */ + private function countOtherFiles(Runner $runner, array $scope): array + { + $masks = array_values(array_map( + fn(string $extension) => "*.$extension", + array_diff(self::OtherExtensions, $this->project->config->fileExtensions), + )); + $counts = []; + foreach ($scope as $path) { + $path = Helpers::toAbsolutePath($path, $this->project->root); + foreach (is_dir($path) ? Finder::findFiles($masks)->from($path) : [] as $file) { + $file = Helpers::canonicalizePath($file->getPathname()); + if (!$runner->files->excludes($file, $this->project->config->paths)) { + $extension = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + $counts[$extension] = ($counts[$extension] ?? 0) + 1; + } + } + } + + ksort($counts); + return $counts; + } + + + /** + * What the lock of the project holds that refuses each target. + * @param list $targets + * @return list + */ + private function findBlockers(Composer $composer, array $targets): array + { + $this->onProgress?->__invoke('asking Composer what the lock refuses'); + return array_map($composer->findBlockers(...), $targets); + } + + + /** + * What raising the constraints to the targets as the plan raises them moves, as the dry run of Composer says, which + * needs the network. + * @param list $targets + */ + private function findMoves(Composer $composer, ProjectPackages $packages, array $targets): Moves + { + $this->onProgress?->__invoke('asking Composer what the update moves'); + $constraints = new Constraints($packages)->raise($targets); + return $constraints === [] ? new Moves : $composer->findMoves($constraints); + } + + + /** + * The packages of upgrading data about a library the project requires, which neither the project nor DressCode has. + * @return array> package => the libraries of the project it is about + */ + private function findMissingData(ProjectPackages $project): array + { + $catalogue = DataPackageCatalogue::load($this->project->getCacheDir(), $this->offline); + $missing = []; + foreach (PackageDiscovery::findMissingDataPackages($project, $catalogue) as $package) { + $missing[$package] = array_values(array_filter( + array_keys($project->required), + fn(string $library) => PackageDiscovery::isAbout($catalogue[$package], $library), + )); + } + + return $missing; + } +} diff --git a/src/Upgrade/FixStep.php b/src/Upgrade/FixStep.php new file mode 100644 index 0000000000..874e91180e --- /dev/null +++ b/src/Upgrade/FixStep.php @@ -0,0 +1,24 @@ + as `--only` takes them, each made and committed apart */ + public array $topics, + public bool $skip = false, + ) { + } +} diff --git a/src/Upgrade/Git.php b/src/Upgrade/Git.php new file mode 100644 index 0000000000..2004c88fc0 --- /dev/null +++ b/src/Upgrade/Git.php @@ -0,0 +1,159 @@ +run(['rev-parse', '--absolute-git-dir'], errors: false); + return $exit === 0 && !$this->ignoresRoot() ? trim($output) : null; + } + + + /** + * Whether the tracked files have changes, the files given aside. + * @param list $except relative to the root + */ + public function readStatus(array $except = []): WorkingTree + { + $excludes = array_map(fn(string $path) => ":(exclude,literal)$path", $except); + [$exit, $status] = $this->run(['status', '--porcelain', '--untracked-files=no', '--', '.', ...$excludes]); + return match (true) { + $exit !== 0, $this->ignoresRoot() => WorkingTree::Unavailable, + trim($status) === '' => WorkingTree::Clean, + default => WorkingTree::Dirty, + }; + } + + + /** + * The files of those given that git tracks or would add, those it ignores aside. + * @param list $paths relative to the root + * @return list + * @throws GitException + */ + public function listUnignored(array $paths): array + { + $arguments = ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...self::toPathspecs($paths)]; + $output = $this->runOrThrow($arguments, errors: false); + return array_values(array_intersect($paths, explode("\0", $output))); + } + + + /** + * Commits the files with the texts given through an index of its own, so that the working tree stays as it is and the + * index changes only in those files, which it then takes as committed; the hooks of the repository run as for any + * commit. + * @param array $files relative to the root => text + * @throws GitException where git refuses, a hook of the repository among the reasons + */ + public function commit(array $files, string $subject, string $body): void + { + $pathspecs = self::toPathspecs(array_map(strval(...), array_keys($files))); + $index = sys_get_temp_dir() . '/dresscode-index-' . bin2hex(random_bytes(8)); + $env = ['GIT_INDEX_FILE' => $index]; + try { + if ($this->run(['rev-parse', '--verify', '--quiet', 'HEAD'])[0] === 0) { + $this->runOrThrow(['read-tree', 'HEAD'], env: $env); + } + + // a file keeps the mode it has, and its text is cleaned by the attributes as `git add` would clean it; the index + // names it from the top of the repository, which may lie above the root + $modes = []; + $staged = $this->runOrThrow(['ls-files', '--stage', '--full-name', '-z', '--', ...$pathspecs], env: $env, errors: false); + foreach (array_filter(explode("\0", $staged)) as $entry) { + [$meta, $name] = explode("\t", $entry, 2) + [1 => '']; + $modes[$name] = explode(' ', $meta)[0]; + } + + $prefix = trim($this->runOrThrow(['rev-parse', '--show-prefix'], errors: false)); + $entries = ''; + foreach ($files as $path => $text) { + $name = FileSystem::unixSlashes(FileSystem::normalizePath($prefix . $path)); + $blob = trim($this->runOrThrow(['hash-object', '-w', '--stdin', "--path=$path"], $text, errors: false)); + $entries .= ($modes[$name] ?? '100644') . " $blob\t$name\0"; + } + + $this->runOrThrow(['update-index', '-z', '--index-info'], input: $entries, env: $env); + $this->runOrThrow(['commit', '--quiet', '-m', $subject, '-m', $body], env: $env); + } finally { + @unlink($index); // @ git may not have written it + @unlink("$index.lock"); // @ left only by a git that failed + } + + $this->runOrThrow(['reset', '--quiet', '--', ...$pathspecs]); + } + + + /** Whether the repository git finds above ignores the root, which then lies in no repository of its own. */ + private function ignoresRoot(): bool + { + return $this->run(['check-ignore', '--quiet', '.'])[0] === 0; + } + + + /** + * @param list $paths + * @return list + */ + private static function toPathspecs(array $paths): array + { + return array_map(fn(string $path) => ":(literal)$path", $paths); + } + + + /** + * The output of git, and where it fails the reason, which only an output with the errors tells. + * @param list $arguments + * @param array $env + * @throws GitException + */ + private function runOrThrow(array $arguments, ?string $input = null, array $env = [], bool $errors = true): string + { + [$exit, $output] = $this->run($arguments, $input, $env, $errors); + if ($exit !== 0) { + $lines = array_filter(array_map(trim(...), explode("\n", $output))); + $reason = end($lines) ?: ($exit === null ? 'it did not end in time' : "exit code $exit"); + throw new GitException("`git $arguments[0]` failed: $reason"); + } + + return $output; + } + + + /** + * @param list $arguments + * @param array $env + * @return array{?int, string} + */ + private function run(array $arguments, ?string $input = null, array $env = [], bool $errors = true): array + { + return ProcessRunner::run(['git', ...$arguments], $this->root, self::Timeout, $input, $errors, $env); + } +} diff --git a/src/Upgrade/ManualStep.php b/src/Upgrade/ManualStep.php new file mode 100644 index 0000000000..53a1377c89 --- /dev/null +++ b/src/Upgrade/ManualStep.php @@ -0,0 +1,30 @@ +target->package} {$this->target->version}"; + } +} diff --git a/src/Upgrade/Move.php b/src/Upgrade/Move.php new file mode 100644 index 0000000000..c1effbfa65 --- /dev/null +++ b/src/Upgrade/Move.php @@ -0,0 +1,67 @@ +from), self::splitVersion($this->to)]; + return $to[0] !== $from[0] || ($to[0] === 0 && $to[1] !== $from[1]); + } + + + /** + * Whether the upgrading data reach the minor version the package moves to, `4.2` of `v4.2.3`, by the newest version + * they know of each library; data of its major version that stop below it, up to `4.1`, do not. + * @param array $knownVersions library => the newest version its data know + */ + public function isKnown(array $knownVersions): bool + { + [$major, $minor] = self::splitVersion($this->to); + $known = $knownVersions[$this->package] ?? null; + return $known !== null && version_compare($known, "$major.$minor", '>='); + } + + + /** + * Whether the upgrading data reach into the major version the package moves to, or the minor one below 1.0, if + * perhaps not up to the version itself. + * @param array $knownVersions library => the newest version its data know + */ + public function isSeriesKnown(array $knownVersions): bool + { + [$major, $minor] = self::splitVersion($this->to); + $known = $knownVersions[$this->package] ?? null; + return $known !== null && version_compare($known, $major === 0 ? "0.$minor" : "$major.0", '>='); + } + + + /** @return array{int, int} the major and the minor number of a version as Composer writes it, `v3.2.10` */ + private static function splitVersion(string $version): array + { + preg_match('~^v?(\d+)(?:\.(\d+))?~', $version, $m); + return [(int) ($m[1] ?? 0), (int) ($m[2] ?? 0)]; + } +} diff --git a/src/Upgrade/Moves.php b/src/Upgrade/Moves.php new file mode 100644 index 0000000000..a79542acc8 --- /dev/null +++ b/src/Upgrade/Moves.php @@ -0,0 +1,35 @@ + */ + public array $packages = [], + /** why Composer gave no answer, null where it gave one */ + public ?string $unknown = null, + ) { + } + + + /** + * The moves across a major version, where a package may break its API. + * @return list + */ + public function findMajor(): array + { + return array_values(array_filter($this->packages, fn(Move $move) => $move->isMajor())); + } +} diff --git a/src/Upgrade/NameResolution.php b/src/Upgrade/NameResolution.php new file mode 100644 index 0000000000..7740578432 --- /dev/null +++ b/src/Upgrade/NameResolution.php @@ -0,0 +1,27 @@ + $constraints package => constraint + * @return list + * @throws ComposerException + */ + function previewRequire(array $constraints, bool $update = true, ?string $platform = null): array; + + /** + * Raises the constraints and updates what they move. + * @param array $constraints package => constraint + * @throws ComposerException + */ + function require(array $constraints, bool $update = true, ?string $platform = null): void; + + /** + * What updating the packages to the newest versions the constraints allow would change. + * @return list + * @throws ComposerException + */ + function previewUpdate(): array; + + /** @throws ComposerException */ + function update(): void; + + /** @throws ComposerException */ + function install(): void; +} diff --git a/src/Upgrade/Plan.php b/src/Upgrade/Plan.php new file mode 100644 index 0000000000..d40fa85e23 --- /dev/null +++ b/src/Upgrade/Plan.php @@ -0,0 +1,81 @@ + */ + public array $targets, + /** @var list */ + public array $steps, + /** the version of DressCode that made the plan */ + public string $toolVersion, + /** @var array package of upgrading data => its version, `php` for the data DressCode ships */ + public array $dataVersions = [], + /** the command testing the whole project, `{junit}` standing for the file it writes its results to */ + public ?string $testCommand = null, + /** the command testing what is quick to test, after every step */ + public ?string $quickTestCommand = null, + /** @var list the paths within the configured ones the fixes are narrowed to, relative to the root; none for all */ + public array $paths = [], + /** @var list the files the estimate could not process, whose places no step counts on, relative to the root */ + public array $unestimated = [], + /** @var array> a package of upgrading data the estimate lacked => the libraries it is about */ + public array $missingData = [], + ) { + } + + + /** Whether the plan was made with what is given, the version of DressCode and those of the upgrading data. */ + public function isMadeWith(Provenance $provenance): bool + { + return $this->toolVersion === $provenance->tool && $this->dataVersions === $provenance->data; + } + + + /** + * The commands of the tests the plan gives, the whole suite first. + * @return list + */ + public function listTestCommands(): array + { + return array_values(array_filter([$this->testCommand, $this->quickTestCommand])); + } + + + /** The command testing after a step: the whole suite where `$whole`, else the quicker one, each standing in for the other. */ + public function selectTestCommand(bool $whole): ?string + { + return $whole ? $this->testCommand ?? $this->quickTestCommand : $this->quickTestCommand ?? $this->testCommand; + } + + + /** + * The steps the run makes, those the user left out aside, by their number from 1. + * @return array + */ + public function findSteps(): array + { + $steps = []; + foreach ($this->steps as $index => $step) { + if (!$step->skip) { + $steps[$index + 1] = $step; + } + } + + return $steps; + } +} diff --git a/src/Upgrade/PlanBuilder.php b/src/Upgrade/PlanBuilder.php new file mode 100644 index 0000000000..ccebd3214e --- /dev/null +++ b/src/Upgrade/PlanBuilder.php @@ -0,0 +1,109 @@ +library; + $constraints = new Constraints($this->project); + $steps = []; + foreach ($estimate->blockers as $blockers) { + if ($blockers->packages !== []) { + $steps[] = new ManualStep( + 'make way for ' + . $blockers->target->format() + . ': ' + . implode(', ', array_map(fn(Blocker $blocker) => $blocker->package, $blockers->packages)), + $blockers->target, + ); + } elseif ($blockers->unknown !== null) { // not knowing what refuses the target is no knowing that nothing does + $steps[] = new ManualStep("make way for {$blockers->target->format()}, which Composer could not tell", $blockers->target); + } + } + + $steps[] = self::createFix('works on the versions allowed today', $estimate->findTopics(Phase::Bridge, 'deprecations')); + $require = $library ? $constraints->widen($estimate->targets) : $constraints->raise($estimate->targets); + // a platform below the target is raised even where composer.json requires the target already + $platform = $library ? null : $constraints->raisePlatform($estimate->targets); + if ($require !== [] || $platform !== null) { + $versions = Target::collectVersions($estimate->targets); + $changes = []; + foreach ($require as $package => $constraint) { + $bound = $library ? null : Constraints::findPassedUpperBound($this->project->required[$package] ?? '', $versions[$package]); + $changes[] = "$package to $constraint" . ($bound === null ? '' : " past its upper bound $bound"); + } + + if ($platform !== null) { + $changes[] = "config.platform.php to $platform"; + } + + $steps[] = new ComposerStep( + ($library ? 'widen ' : 'raise ') . implode(', ', $changes), + $require, + update: !$library, + platform: $platform, + ); + } + + if (!$library) { + $steps[] = self::createFix('needs the targets', $estimate->findTopics(Phase::Jump, 'deprecations')); + $steps[] = self::createFix('code for the versions given up', [ + ...$estimate->findTopics(Phase::Bridge, 'cleanup'), + ...$estimate->findTopics(Phase::Jump, 'cleanup'), + ]); + // the raise installs the lowest versions, which the code is written for, and the application runs the newest + if ($require !== []) { + $steps[] = new UpdateStep('update to the newest versions'); + } + } + + $steps[] = self::createFix('what the targets offer', [ + ...$estimate->findTopics(Phase::Bridge, 'modernizations'), + ...$library ? [] : $estimate->findTopics(Phase::Jump, 'modernizations'), + ], skip: true); + + $provenance = Provenance::current($this->project); + return new Plan( + $estimate->targets, + array_values(array_filter($steps)), + $provenance->tool, + $provenance->data, + $testCommand, + $quickTestCommand, + $estimate->paths, + array_keys($estimate->unprocessed), + $estimate->missingData, + ); + } + + + /** @param list $topics */ + private static function createFix(string $title, array $topics, bool $skip = false): ?FixStep + { + $names = array_values(array_unique(array_map(fn(Topic $topic) => $topic->name, $topics))); + return $names === [] ? null : new FixStep($title, $names, $skip); + } +} diff --git a/src/Upgrade/PlanFile.php b/src/Upgrade/PlanFile.php new file mode 100644 index 0000000000..a8ad9bed3f --- /dev/null +++ b/src/Upgrade/PlanFile.php @@ -0,0 +1,301 @@ + its kind, the word of the file */ + private const Kinds = [ + FixStep::class => 'fix', + ComposerStep::class => 'composer', + UpdateStep::class => 'update', + ManualStep::class => 'manual', + ]; + + /** the keys of a plan */ + private const Keys = ['version', 'toolVersion', 'dataVersions', 'targets', 'paths', 'tests', 'estimate', 'steps']; + + /** the keys of a step that its kind takes, beside `title`, `kind` and `skip` */ + private const StepKeys = ['fix' => ['topics'], 'composer' => ['require', 'update', 'platform'], 'update' => [], 'manual' => ['target']]; + + + /** Whether the project in the root has a saved plan. */ + public static function exists(string $root): bool + { + return is_file("$root/" . self::Name); + } + + + /** The identity of the saved plan, a hash of its text, by which a tool tells one plan from another; null without one. */ + public static function identify(string $root): ?string + { + $text = @file_get_contents("$root/" . self::Name); // @ the plan may not be saved + return $text === false ? null : substr(hash('xxh128', $text), 0, 16); + } + + + /** + * The saved plan of the project in the root. + * @throws ConfigurationException for a file that is no plan + */ + public static function read(string $root): Plan + { + $file = "$root/" . self::Name; + try { + $data = Neon::decode(FileSystem::read($file)); + } catch (NeonException|IOException $e) { + throw new ConfigurationException("The plan `$file` cannot be read: {$e->getMessage()}", previous: $e); + } + + $fail = fn(string $what) => throw new ConfigurationException("The plan `$file`: $what."); + if (!is_array($data)) { + $fail('a map of `version`, `targets` and `steps` is expected'); + } elseif (($data['version'] ?? null) !== self::Version) { + $fail('`version: ' . self::Version . '` is expected, the plan of another version of DressCode is made anew'); + } elseif (($unknown = array_diff(array_keys($data), self::Keys)) !== []) { + $fail('`' . reset($unknown) . '` is no key of a plan'); + } + + $targets = []; + foreach (self::readMap($data['targets'] ?? [], 'targets', $fail) as $package => $version) { + if (!Target::isPackageName($package)) { + $fail("`targets.$package` must be `php` or a package as `vendor/name`"); + } elseif (!Versions::isVersion($version)) { + $fail("`targets.$package` must be a version, as `'8.4'`"); + } + + $targets[] = new Target($package, $version); + } + + $tests = self::readMap($data['tests'] ?? [], 'tests', $fail); + if (($unknown = array_diff(array_keys($tests), ['command', 'quick'])) !== []) { + $fail('`tests.' . reset($unknown) . '` is no key of `tests`, which takes `command` and `quick`'); + } + + foreach ($tests as $key => $command) { + if (!TestResults::hasJunitPlaceholder($command)) { + $fail("`tests.$key` must be a command writing its JUnit report where `{junit}` stands"); + } + } + + $estimate = $data['estimate'] ?? []; + if (!is_array($estimate) || ($unknown = array_diff(array_keys($estimate), ['unprocessed', 'missingData'])) !== []) { + $fail(is_array($estimate) + ? '`estimate.' . reset($unknown) . '` is no key of `estimate`, which takes `unprocessed` and `missingData`' + : '`estimate` must be a map'); + } + + $missingData = []; + $missing = $estimate['missingData'] ?? []; + foreach (is_array($missing) ? $missing : $fail('`estimate.missingData` must be a map of lists') as $package => $libraries) { + $missingData[(string) $package] = self::readList($libraries, "estimate.missingData.$package", $fail); + } + + $steps = $data['steps'] ?? []; + if (!is_array($steps) || !array_is_list($steps)) { + $fail('`steps` must be a list'); + } + + foreach ($steps as $index => $step) { + $steps[$index] = self::readStep($step, 'steps.' . ($index + 1), $targets, $fail); + } + + if ($targets === [] || $steps === []) { + $fail('`targets` and `steps` are expected'); + } + + return new Plan( + $targets, + $steps, + self::readString($data['toolVersion'] ?? '', 'toolVersion', $fail), + self::readMap($data['dataVersions'] ?? [], 'dataVersions', $fail), + $tests['command'] ?? null, + $tests['quick'] ?? null, + self::readList($data['paths'] ?? [], 'paths', $fail), + self::readList($estimate['unprocessed'] ?? [], 'estimate.unprocessed', $fail), + $missingData, + ); + } + + + /** Saves the plan into the root of its project. */ + public static function write(Plan $plan, string $root): void + { + $steps = array_map(fn(Step $step) => [...self::encodeStep($step), ...$step->skip ? ['skip' => true] : []], $plan->steps); + $data = array_filter([ + 'version' => self::Version, + 'toolVersion' => $plan->toolVersion, + 'dataVersions' => $plan->dataVersions, + 'targets' => Target::collectVersions($plan->targets), + 'paths' => $plan->paths, + 'tests' => array_filter(['command' => $plan->testCommand, 'quick' => $plan->quickTestCommand]), + 'estimate' => array_filter(['unprocessed' => $plan->unestimated, 'missingData' => $plan->missingData]), + 'steps' => $steps, + ], fn($value) => $value !== []); + $header = '# dresscode upgrade: the plan for ' . implode(', ', array_map(fn(Target $target) => $target->format(), $plan->targets)) + . "; set `skip: true` to leave a step out, and `dresscode upgrade run` runs what is left\n\n"; + Helpers::replaceFile("$root/" . self::Name, $header . Neon::encode($data, blockMode: true)); + } + + + /** + * The step as the plan file writes it, `skip` aside. + * @return array + */ + public static function encodeStep(Step $step): array + { + return [ + 'title' => $step->title, + 'kind' => self::Kinds[$step::class], + ...match (true) { + $step instanceof FixStep => ['topics' => $step->topics], + $step instanceof ComposerStep => [ + 'require' => $step->require, + ...$step->update ? [] : ['update' => false], + ...$step->platform === null ? [] : ['platform' => $step->platform], + ], + $step instanceof ManualStep => ['target' => $step->target->package], + default => [], + }, + ]; + } + + + /** + * @param list $targets those of the plan, one of which a manual step makes way for + * @param \Closure(string): never $fail + */ + private static function readStep(mixed $step, string $at, array $targets, \Closure $fail): Step + { + $kind = is_array($step) ? $step['kind'] ?? null : null; + if (!is_string($kind) || !isset(self::StepKeys[$kind])) { + $fail("`$at` must give its `kind`, `fix`, `composer`, `update` or `manual`"); + } elseif (($unknown = array_diff(array_keys($step), ['title', 'kind', 'skip', ...self::StepKeys[$kind]])) !== []) { + $fail("`$at." . reset($unknown) . "` is no key of a step of the kind `$kind`"); + } + + $title = self::readString($step['title'] ?? $kind, "$at.title", $fail); + $skip = self::readBool($step['skip'] ?? false, "$at.skip", $fail); + return match ($kind) { + 'fix' => new FixStep( + $title, + self::readList($step['topics'] ?? [], "$at.topics", $fail) ?: $fail("`$at.topics` must name the topics the step fixes"), + $skip, + ), + 'composer' => new ComposerStep( + $title, + self::readConstraints( + $step['require'] ?? [], + "$at.require", + $fail, + required: !isset($step['platform']), + ), + self::readBool($step['update'] ?? true, "$at.update", $fail), + isset($step['platform']) ? self::readVersion($step['platform'], "$at.platform", $fail) : null, + $skip, + ), + 'update' => new UpdateStep($title, $skip), + default => new ManualStep( + $title, + array_find($targets, fn(Target $target) => $target->package === ($step['target'] ?? null)) + ?? $fail("`$at.target` must name a package of `targets`"), + $skip, + ), + }; + } + + + /** + * @param \Closure(string): never $fail + * @return array package => constraint + */ + private static function readConstraints(mixed $value, string $at, \Closure $fail, bool $required): array + { + $require = self::readMap($value, $at, $fail); + if ($require === [] && $required) { + $fail("`$at` must give the constraints the step writes, or the step its `platform`"); + } + + foreach ($require as $package => $constraint) { + if (!Target::isPackageName($package)) { + $fail("`$at.$package` must be `php` or a package as `vendor/name`"); + } elseif (!Versions::isConstraint($constraint)) { + $fail("`$at.$package` must be a constraint Composer reads, as `^8.4`"); + } + } + + return $require; + } + + + /** + * @param \Closure(string): never $fail + * @return list + */ + private static function readList(mixed $value, string $at, \Closure $fail): array + { + return is_array($value) && array_is_list($value) && array_all($value, fn($item) => is_string($item)) + ? $value + : $fail("`$at` must be a list of strings"); + } + + + /** + * @param \Closure(string): never $fail + * @return array + */ + private static function readMap(mixed $value, string $at, \Closure $fail): array + { + return is_array($value) && array_all($value, fn($item, $key) => is_string($key) && is_string($item)) + ? $value + : $fail("`$at` must be a map of strings, a version quoted as `'8.4'`"); + } + + + /** @param \Closure(string): never $fail */ + private static function readVersion(mixed $value, string $at, \Closure $fail): string + { + return is_string($value) && Versions::isVersion($value) ? $value : $fail("`$at` must be a version, as `'8.4'`"); + } + + + /** @param \Closure(string): never $fail */ + private static function readString(mixed $value, string $at, \Closure $fail): string + { + return is_string($value) ? $value : $fail("`$at` must be a string"); + } + + + /** @param \Closure(string): never $fail */ + private static function readBool(mixed $value, string $at, \Closure $fail): bool + { + return is_bool($value) ? $value : $fail("`$at` must be `true` or `false`"); + } +} diff --git a/src/Upgrade/PlanRunner.php b/src/Upgrade/PlanRunner.php new file mode 100644 index 0000000000..50ea08ea0e --- /dev/null +++ b/src/Upgrade/PlanRunner.php @@ -0,0 +1,619 @@ +configFile = $project->configFile ?? throw new ConfigurationException( + 'The upgrade rewrites the code in the style the configuration of the project keeps, and the project has none; write it with `dresscode init` first.', + ); + $this->state = new RunState($project); + $this->git = $git ?? new Git($project->root); + $this->composer = $composer ?? Composer::forProject($project->root); + $this->tests = $tests ?? new ProcessTestRunner; + } + + + /** Whether the tracked files of the working tree have changes of their own, which the changes of a run mix with. */ + public function hasOwnChanges(): bool + { + return $this->git->readStatus([PlanFile::Name]) === WorkingTree::Dirty; + } + + + /** + * Runs the steps the plan does not leave out, or with `$singleStep` that one alone; a manual step passes once + * Composer says nothing of the lock refuses its target, and a step of Composer that is not done yet is made once + * `$approve` agrees to what Composer says it changes; a raise stops a run of the whole plan, which `$resumed` goes on + * after in a process that loads what the raise installed. Where a run was interrupted in a step, it runs nothing + * unless `$discard` says to go on from the files as they are. + * @param ?\Closure(DependencyStep, list): bool $approve told the step and the operations of Composer it makes + * @throws ConfigurationException + */ + public function run( + Plan $plan, + ?int $singleStep = null, + ?\Closure $approve = null, + bool $discard = false, + ?RunOutcome $resumed = null, + ): RunOutcome + { + $warnings = []; + $outcome = $this->state->runExclusively(function () use ($plan, $singleStep, $approve, $discard, $resumed, &$warnings) { + return $this->runSteps($plan, $singleStep, $approve, $discard, $resumed, $warnings); + }); + return $warnings === [] ? $outcome : $outcome->withWarnings($warnings); + } + + + /** + * @param ?\Closure(DependencyStep, list): bool $approve + * @param list $warnings what the run tells the user beside its outcome, filled as it goes + * @throws ConfigurationException + */ + private function runSteps( + Plan $plan, + ?int $singleStep, + ?\Closure $approve, + bool $discard, + ?RunOutcome $resumed, + array &$warnings, + ): RunOutcome + { + $steps = $plan->findSteps(); + if ($singleStep !== null && !isset($steps[$singleStep])) { + throw new ConfigurationException(isset($plan->steps[$singleStep - 1]) + ? "Step $singleStep of the plan is left out with `skip: true`, so it is not run." + : "The plan has no step $singleStep; it has " . count($plan->steps) . '.'); + } + + // a step that left files it could not process is committed once a later run makes it whole + $uncommitted = $this->commit ? $this->findUncommitted($steps, $discard) : null; + $status = $this->commit + ? $this->git->readStatus([PlanFile::Name, ...$uncommitted?->listOwnFiles($this->project->root) ?? []]) + : null; + if ($status === WorkingTree::Unavailable) { + throw new ConfigurationException('`--commit` commits into the git repository of the project, and git finds none there that tracks it, or refuses to tell its status.'); + } elseif ($status === WorkingTree::Dirty) { + throw new ConfigurationException('A commit of a topic takes the files it changed whole, and the working tree has changes of its own; commit or stash them first.'); + } + + $snapshots = $this->state->snapshots; + foreach ($snapshots->findInterrupted() as $number => $files) { + if (!$discard) { + return new RunOutcome(RunStop::Interrupted, $number, interrupted: $files); + } + + $snapshots->drop($number); + } + + $baselines = []; + foreach ($plan->listTestCommands() as $command) { + $baselines[$command] = $this->findBaseline($command); + if ($baselines[$command]->crash !== null) { + $crash = "the tests of `$command` do not run before the first step, as {$baselines[$command]->crash}"; + return new RunOutcome(RunStop::Tests, null, crash: $crash); + } + } + + $satisfiedRequirements = $this->project->measureSatisfiedRequirements(); + $names = $this->project->resolveNames(); + $declarations = $names->declarations; + if ($names->warning !== null) { + $warnings[] = $names->warning; + } + + $lastFixStep = array_key_last(array_filter($steps, fn(Step $step) => $step instanceof FixStep)); + $applied = $resumed->applied ?? []; + $tasks = $resumed->tasks ?? []; + $followsRaise = $resumed !== null; + // the step of Composer whose raise no tests have run after yet, which the journal keeps across the runs + $untestedRaise = $resumed->step ?? $this->state->journal->findStep(); + $testBaseline = null; // what the last tests were compared with + $lastTaskStep = max([0, ...array_keys(array_filter($applied, fn(array $topics) => array_intersect_key($tasks, $topics) !== []))]); + foreach ($steps as $number => $step) { + if (($singleStep !== null && $number !== $singleStep) || $number <= ($resumed->step ?? 0)) { + continue; + } elseif ($step instanceof ManualStep) { + $blockers = $this->findBlockers($number, $step); + if ($blockers !== null) { + return new RunOutcome(RunStop::Manual, $number, $applied, $tasks, blockers: $blockers); + } + + continue; + + } elseif ($step instanceof DependencyStep) { + try { + // whether every package is the newest the constraints allow only Composer tells + $changes = $step instanceof UpdateStep ? $this->composer->previewUpdate() : null; + $due = $changes === null + ? $step instanceof ComposerStep && !$step->isDoneIn(ProjectPackages::read($this->project->root)) + : $changes !== []; + $done = $due ? $this->runComposerStep($number, $step, $approve, $changes) : null; + } catch (ComposerException $e) { + $done = $e->getMessage(); + } catch (CommitException $e) { + return new RunOutcome( + RunStop::Commit, + $number, + $applied, + $tasks, + raisedFiles: $this->state->journal->findChanged(), + commitFailure: $e->getMessage(), + ); + } + + if ($done !== null) { + if ($done !== true) { + return new RunOutcome( + RunStop::Composer, + $number, + $applied, + $tasks, + raisedFiles: $this->state->journal->findChanged(), + composerFailure: $done ?: null, + ); + } + + $untestedRaise = $number; + // what the raise installed is loaded anew for what runs after it, the check at the end of the plan among it + if ($singleStep === null) { + return new RunOutcome(RunStop::Raised, $number, $applied, $tasks); + } + } + + $followsRaise = true; + continue; + + } elseif (!$step instanceof FixStep) { + continue; + } + + $result = $this->fixStep($number, $step, $plan->paths, $satisfiedRequirements, $declarations); + $warnings = array_values(array_unique([...$warnings, ...$result->warnings])); + $unprocessed = $result->unprocessed; + + // the whole suite after the jump and after the last step, the quick one after the others + $command = $plan->selectTestCommand(whole: $followsRaise || $number === $lastFixStep); + if ($command !== null) { + $this->onProgress?->__invoke("step $number, running the tests"); + $testBaseline = $baselines[$command]; + $untestedRaise = null; + $failure = $this->runTests($command, $testBaseline); + if ($failure !== null) { + $unprocessed += $snapshots->restore($number, $this->project->root); + return new RunOutcome( + RunStop::Tests, + $number, + $applied, + $tasks, + $failure->newFailures, + $unprocessed, + crash: $failure->crash, + baseline: $testBaseline, + raisedFiles: $this->state->journal->findChanged(), + ); + } + } + + // a step its tests sent back applied nothing + $applied[$number] = array_map(array_keys(...), $result->written); + $snapshots->drop($number); + if ($followsRaise) { // the raise holds once a step after it is done + $this->state->journal->forget(); + } + + try { + if ($this->commit) { + $this->commitStep($number, $step, $result->written, array_keys($unprocessed), $uncommitted); + } + } catch (CommitException $e) { + return new RunOutcome( + RunStop::Commit, + $number, + $applied, + array_filter([...$tasks, ...$result->tasks]), + commitFailure: $e->getMessage(), + ); + } + + $tasks = array_filter([...$tasks, ...$result->tasks]); // the last fix of a topic tells what it leaves + $lastTaskStep = array_filter($result->tasks) === [] ? $lastTaskStep : $number; + if ($unprocessed !== []) { + return new RunOutcome(RunStop::Unprocessed, $number, $applied, $tasks, unprocessed: $unprocessed, baseline: $testBaseline); + } elseif ($followsRaise && $lastTaskStep === $number) { + return new RunOutcome(RunStop::Tasks, $number, $applied, $tasks, baseline: $testBaseline); + } + + $followsRaise = false; + } + + // a raise no fix followed is tested after it all the same, the whole suite + $command = $plan->selectTestCommand(whole: true); + if ($untestedRaise !== null && $command !== null) { + $this->onProgress?->__invoke("step $untestedRaise, running the tests"); + $testBaseline = $baselines[$command]; + $failure = $this->runTests($command, $testBaseline); + if ($failure !== null) { + return new RunOutcome( + RunStop::Tests, + $untestedRaise, + $applied, + $tasks, + $failure->newFailures, + crash: $failure->crash, + baseline: $testBaseline, + raisedFiles: $this->state->journal->findChanged(), + ); + } + + $this->state->journal->forget(); + } + + if ($tasks !== []) { // a plan is done once nothing of it is left for a human + return new RunOutcome(RunStop::Tasks, $lastTaskStep, $applied, $tasks, baseline: $testBaseline); + } elseif ($singleStep === null) { + // and once the targets find nothing in the whole scope, which the estimate the plan was made from may have missed + $this->onProgress?->__invoke('checking the code against the targets'); + $check = $this->runTopic('deprecations', $satisfiedRequirements, $declarations, $plan->paths); + if ($check->unprocessed !== []) { + return new RunOutcome(RunStop::Unprocessed, null, $applied, unprocessed: $check->unprocessed, baseline: $testBaseline); + } elseif ($check->changed !== [] || $check->tasks !== []) { + return new RunOutcome( + RunStop::Remaining, + null, + $applied, + array_filter(['deprecations' => $check->tasks]), + baseline: $testBaseline, + remaining: array_keys($check->changed), + ); + } + + // a place put off without a reason is no more done than one left, as `upgrade tasks` says + $postponed = Postponement::findIn($this->project, $plan->paths); + if ($postponed !== []) { + return new RunOutcome(RunStop::Tasks, null, $applied, baseline: $testBaseline, postponed: $postponed); + } + + $this->state->remove(); + } + + return new RunOutcome(RunStop::Done, null, $applied, $tasks, baseline: $testBaseline); + } + + + /** + * The step a run left uncommitted, which the plan must still have under its number, unless told to forget it. + * @param array $steps + * @throws ConfigurationException + */ + private function findUncommitted(array $steps, bool $discard): ?UncommittedStep + { + $uncommitted = $this->state->findUncommitted(); + $step = $uncommitted === null ? null : $steps[$uncommitted->step] ?? null; + if ($uncommitted === null || ($step !== null && UncommittedStep::identify($step) === $uncommitted->identity)) { + return $uncommitted; + } elseif (!$discard) { + throw new ConfigurationException("The plan changed since a run left step $uncommitted->step uncommitted, waiting for files it could not process; commit what it changed yourself, and `upgrade run --discard` forgets it."); + } + + $this->state->forgetUncommitted(); + return null; + } + + + /** What refuses the target of the manual step, or why Composer could not tell; null where nothing does. */ + private function findBlockers(int $number, ManualStep $step): ?Blockers + { + $this->onProgress?->__invoke("step $number, asking Composer what refuses {$step->target->format()}"); + $blockers = $this->composer->findBlockers($step->target); + return $blockers->isClear() ? null : $blockers; + } + + + /** + * Whether the step was made, once `$approve` agreed to what Composer says it changes, the files of Composer kept as + * they were before, and with commits the raise committed. + * @param ?\Closure(DependencyStep, list): bool $approve + * @param ?list $changes what Composer said the step changes, where it was asked already + * @throws ComposerException + */ + private function runComposerStep(int $number, DependencyStep $step, ?\Closure $approve, ?array $changes): bool + { + if ($approve === null) { + return false; + } + + $changes ??= $step instanceof ComposerStep ? $this->composer->previewRequire($step->require, $step->update, $step->platform) : []; + if (!$approve($step, $changes)) { + return false; + } + + $root = $this->project->root; + $composerRoot = $this->project->findComposerRoot(); + $journal = $this->state->journal; + $journal->recordBefore($number, $composerRoot); + try { + if ($step instanceof ComposerStep) { + $this->composer->require($step->require, $step->update, $step->platform); + } else { + $this->composer->update(); + } + } finally { + $journal->recordAfter($composerRoot); + } + + $raised = !$step instanceof ComposerStep || $step->isDoneIn(ProjectPackages::read($root)); + + if ($journal->findChanged() === []) { + $journal->forget(); + } + + if (!$raised) { + return false; + } elseif ($this->commit) { + try { + // the files of Composer as git names them from the root, which may lie below them + $up = str_repeat('../', substr_count(substr($root, strlen($composerRoot)), '/')); + $paths = $this->git->listUnignored(["{$up}composer.json", "{$up}composer.lock"]); + if ($paths !== []) { + $files = array_combine($paths, array_map(fn(string $path) => FileSystem::read("$root/$path"), $paths)); + $this->git->commit($files, "upgrade: $step->title", "DressCode-Upgrade-Step: $number"); + } + } catch (GitException $e) { + throw new CommitException("{$e->getMessage()}; the raise of step $number is left uncommitted.", previous: $e); + } + } + + return true; + } + + + /** + * Fixes the topics of the step one by one and writes what each changes. + * @param list $paths + * @param list $satisfied + */ + private function fixStep(int $number, FixStep $step, array $paths, array $satisfied, ?NamespaceDeclarations $declarations): StepResult + { + $written = $unprocessed = $tasks = $warnings = []; + foreach ($step->topics as $topic) { + $this->onProgress?->__invoke("step $number, fixing $topic"); + $reporter = $this->runTopic($topic, $satisfied, $declarations, $paths); + [$written[$topic], $unwritten] = $this->writeTopic($number, $reporter); + $unprocessed += $reporter->unprocessed + $unwritten; + $tasks[$topic] = $reporter->tasks; + $warnings = [...$warnings, ...$reporter->warnings]; + } + + return new StepResult($written, $unprocessed, $tasks, $warnings); + } + + + /** Runs the tests and compares them with the baseline; null where they pass. */ + private function runTests(string $command, TestResults $baseline): ?TestFailure + { + $results = $this->tests->run($command, $this->project->root); + $failures = $results->findNewFailures($baseline); + return $failures === [] && $results->crash === null ? null : new TestFailure($failures, $results->crash); + } + + + /** + * The results of the command of the tests before the first step, run once and kept for the runs that follow, each + * command its own; results whose command crashed are not kept. + */ + private function findBaseline(string $command): TestResults + { + $baseline = $this->state->findBaseline($command); + if ($baseline === null) { + $this->onProgress?->__invoke('running the tests before the first step'); + $baseline = $this->tests->run($command, $this->project->root); + if ($baseline->crash !== null) { + return $baseline; + } + + $this->state->saveBaseline($command, $baseline); + } + + return $baseline; + } + + + /** + * What the fixes of the steps the plan does not leave out leave for a human in the code as it is now, and what the + * check of the whole scope at the end of a run leaves besides, under `deprecations`, and the files they cannot + * process, changing nothing; a task confirmed by a suppression or the baseline is done, and so is not among them. + * @return array{array>, array} topic => its tasks, and file => why + * @throws ConfigurationException + */ + public function findTasks(Plan $plan): array + { + $satisfied = $this->project->measureSatisfiedRequirements(); + $declarations = $this->project->resolveNames()->declarations; + $tasks = $unprocessed = $found = []; + foreach ($plan->findSteps() as $step) { + foreach ($step instanceof FixStep ? $step->topics : [] as $topic) { + $this->onProgress?->__invoke("looking at $topic"); + $reporter = $this->runTopic($topic, $satisfied, $declarations, $plan->paths); + $tasks[$topic] = $reporter->tasks; + $unprocessed += $reporter->unprocessed; + foreach ($reporter->tasks as $task) { + $found[self::identifyTask($task)] = true; + } + } + } + + $this->onProgress?->__invoke('checking the code against the targets'); + $check = $this->runTopic('deprecations', $satisfied, $declarations, $plan->paths); + $tasks['deprecations'] = array_values(array_filter($check->tasks, fn(Task $task) => !isset($found[self::identifyTask($task)]))); + $unprocessed += $check->unprocessed; + return [array_filter($tasks), $unprocessed]; + } + + + /** The place and the message of the task, the same for a topic and for the check of the whole scope finding it. */ + private static function identifyTask(Task $task): string + { + $violation = $task->violation; + return implode("\0", [$task->path, $violation->line, $violation->decision, $violation->message]); + } + + + /** + * Runs the topic over the project as it is, writing nothing, the names resolved for certain with what the namespaces + * declare where they were measured, as the estimate resolves them. + * @param list $satisfied the requirements of the style the code satisfies + * @param list $paths within the configured ones, all of them where empty + */ + private function runTopic(string $topic, array $satisfied, ?NamespaceDeclarations $declarations, array $paths): TopicReporter + { + $project = $this->project; + $layer = $project->createLayer($project->discoverPackages()->findDataPlugins(), $this->types, declarations: $declarations); + [$factory, $resolution] = new UpgradeCheck($layer, [$topic, ...$satisfied], Estimator::Sets)->resolve( + $project->config, $project->root, + ); + $runner = $factory->createRunner($resolution, cache: false, configFile: $this->configFile); + $reporter = new TopicReporter(array_keys($resolution->warnings)); + $runner->run($project->findUpgradeFiles($paths)->files, false, $reporter); + return $reporter; + } + + + /** + * Writes what the fix of the topic changes, each file kept as it was before the step first, and leaves a file changed + * since the fix read it as it is. + * @return array{array, array} the files written => their text, those not => why + */ + private function writeTopic(int $step, TopicReporter $reporter): array + { + $written = $unwritten = []; + foreach ($reporter->changed as $path => [$before, $after]) { + $file = Helpers::toAbsolutePath($path, $this->project->root); + if (@file_get_contents($file) !== $before) { // @ a file deleted since is changed too + $unwritten[$path] = 'The file changed while it was being fixed, so it was not written.'; + continue; + } + + $this->state->snapshots->record($step, $path, $before, $after); + if (!Helpers::writeFile($file, $after)) { + $unwritten[$path] = 'The file cannot be written.'; + continue; + } + + $written[$path] = $after; + } + + return [$written, $unwritten]; + } + + + /** + * Commits each topic of a step that processed every file, together with what the run before wrote in it and left + * uncommitted, and otherwise keeps the step as uncommitted for the run that processes the rest. + * @param array> $written topic => the files its fix wrote => their text + * @param list $unprocessed + * @throws ConfigurationException where git refuses a commit + */ + private function commitStep(int $number, FixStep $step, array $written, array $unprocessed, ?UncommittedStep $uncommitted): void + { + foreach ($uncommitted?->step === $number ? $uncommitted->topics : [] as $topic => $paths) { + foreach ($paths as $path) { + $file = Helpers::toAbsolutePath($path, $this->project->root); + if (!isset($written[$topic][$path]) && is_file($file)) { + $written[$topic][$path] = FileSystem::read($file); + } + } + } + + $texts = array_merge(...array_values($written)); // the text a later topic wrote last + if ($unprocessed !== []) { + $this->state->saveUncommitted(new UncommittedStep( + $number, + UncommittedStep::identify($step), + array_map(array_keys(...), $written), + $unprocessed, + array_map(fn(string $text) => hash('xxh128', $text), $texts), + )); + return; + } + + // the commits are of the texts the tests passed over, which a file changed since is not + $changed = array_keys(array_filter( + $texts, + fn(string $text, string $path) => @file_get_contents(Helpers::toAbsolutePath($path, $this->project->root)) !== $text, // @ a file deleted since is changed too + ARRAY_FILTER_USE_BOTH, + )); + if ($changed !== []) { + throw new CommitException('`' . implode('`, `', $changed) . "` changed after step $number wrote it, so the step is left uncommitted in the working tree; commit it yourself."); + } + + $this->commitTopics($number, $written); + $this->state->forgetUncommitted(); + } + + + /** + * Commits each topic of the step with the files it changed as the topic left them, which a later topic of the step + * may have changed again, and leaves the working tree as the last topic left it. + * @param array> $written topic => the files its fix wrote => their text + * @throws ConfigurationException where git refuses a commit + */ + private function commitTopics(int $step, array $written): void + { + $committed = []; + foreach (array_filter($written) as $topic => $files) { + try { + $this->git->commit($files, "upgrade: $topic", "DressCode-Upgrade-Step: $step"); + } catch (GitException $e) { + throw new CommitException( + "{$e->getMessage()}; what step $step changed is left uncommitted in the working tree" + . ($committed === [] ? '.' : ', but for the topics committed before: ' . implode(', ', $committed) . '.'), + previous: $e, + ); + } + + $committed[] = $topic; + } + } +} diff --git a/src/Upgrade/PlanStatus.php b/src/Upgrade/PlanStatus.php new file mode 100644 index 0000000000..52fc1fddde --- /dev/null +++ b/src/Upgrade/PlanStatus.php @@ -0,0 +1,81 @@ + number of the step => what is known of it */ + public array $steps, + /** @var array> step => the files a run interrupted in it may have rewritten */ + public array $interrupted, + /** the step whose raise the journal can go back before; null where it keeps none */ + public ?int $raisedStep, + /** @var list the files of Composer that raise changed and are not put back yet */ + public array $raisedFiles, + /** the files of Composer were put back, and the packages of the lock are still to be installed */ + public bool $awaitingInstall, + /** the step a run with `--commit` left uncommitted; null where none */ + public ?int $uncommittedStep, + /** @var array command of the tests => when its baseline ran, null where it has none yet */ + public array $baselines, + ) { + } + + + /** Reads the plan against the project and what a run keeps, asking Composer only what the lock refuses. */ + public static function read(Plan $plan, Project $project): self + { + $state = new RunState($project); + $packages = ProjectPackages::read($project->root); + $composer = Composer::forProject($project->root); + $steps = []; + foreach ($plan->steps as $index => $step) { + $steps[$index + 1] = match (true) { + $step->skip => StepStatus::Skipped, + $step instanceof ComposerStep => $step->isDoneIn($packages) ? StepStatus::Done : StepStatus::Due, + $step instanceof ManualStep => self::findManualStatus($step, $composer), + default => StepStatus::Unknown, + }; + } + + $baselines = []; + foreach ($plan->listTestCommands() as $command) { + $baselines[$command] = $state->findBaseline($command)?->time; + } + + return new self( + $steps, + $state->snapshots->findInterrupted(), + $state->journal->findStep(), + $state->journal->findChanged(), + $state->journal->isAwaitingInstall(), + $state->findUncommitted()?->step, + $baselines, + ); + } + + + private static function findManualStatus(ManualStep $step, PackageManager $composer): StepStatus + { + $blockers = $composer->findBlockers($step->target); + return match (true) { + $blockers->unknown !== null => StepStatus::Unknown, + $blockers->isClear() => StepStatus::Done, + default => StepStatus::Due, + }; + } +} diff --git a/src/Upgrade/Postponement.php b/src/Upgrade/Postponement.php new file mode 100644 index 0000000000..603024f679 --- /dev/null +++ b/src/Upgrade/Postponement.php @@ -0,0 +1,72 @@ + $paths + * @return list + * @throws ConfigurationException + */ + public static function findIn(Project $project, array $paths = []): array + { + $found = []; + foreach ($project->findUpgradeFiles($paths)->files as $path) { + $code = FileSystem::read(Helpers::toAbsolutePath($path, $project->root)); + foreach (@\PhpToken::tokenize($code) as $token) { // @ the lexer warns of the code, which is the check's to report + $directive = $token->is([T_COMMENT, T_DOC_COMMENT]) ? Suppression::findDirective($token->text) : null; + if ( + $directive !== null + && $directive['kind'] === 'ignore' + && str_starts_with($directive['text'], 'dresscode:') + && $directive['reason'] === '' + && self::namesUpgrade($directive['names']) + ) { + $line = $token->line + substr_count($token->text, "\n", 0, $directive['offset']); + $found[] = new self($path, $line, $directive['text']); + } + } + } + + return $found; + } + + + /** Whether a name of the list is about an upgrade: a decision of upgrading, a package of upgrading data, a library, PHP. */ + private static function namesUpgrade(string $names): bool + { + return array_any( + preg_split('~[,\s]+~', $names, -1, PREG_SPLIT_NO_EMPTY) ?: [], + fn(string $name) => str_starts_with($name, 'upgrading') + || str_starts_with($name, 'upgrade-') + || $name === 'php' + || str_starts_with($name, 'php.') + || str_contains($name, '/'), + ); + } +} diff --git a/src/Upgrade/ProcessTestRunner.php b/src/Upgrade/ProcessTestRunner.php new file mode 100644 index 0000000000..b3768e6105 --- /dev/null +++ b/src/Upgrade/ProcessTestRunner.php @@ -0,0 +1,45 @@ + 'the command of the tests did not end within ' . self::Timeout . ' s', + $results === null => 'the command of the tests wrote no JUnit report where `' . TestResults::Placeholder . '` stands', + $results->tests === [] => 'the report of the tests holds no test', + $exit === 0, in_array($exit, self::FailingExitCodes, true) && $results->countNotPassed() > 0 => null, + default => "the command of the tests ended with exit code $exit" . ($lines === [] ? '' : ': ' . end($lines)), + }; + return new TestResults($results->tests ?? [], $crash, time()); + } +} diff --git a/src/Upgrade/Project.php b/src/Upgrade/Project.php new file mode 100644 index 0000000000..2eccb3c5ee --- /dev/null +++ b/src/Upgrade/Project.php @@ -0,0 +1,205 @@ +rootPath === null) { + throw new ConfigurationException('There is nothing to upgrade: the directory has no configuration of DressCode and no `composer.json`.'); + } + + $scope = Scope::fromProject($root, null); + $config = new Config(paths: $scope->listPaths(), excludePaths: array_keys($scope->listExcludePaths())); + } + + return new self($config, $root, $configFile, $commandLine); + } + + + /** + * The packages of upgrading data that reach the project, with the versions of the targets where given. + * @param array $targets package => version + */ + public function discoverPackages(array $targets = []): PackageDiscovery + { + return PackageDiscovery::discover( + new RunnerFactory()->readProjectPackages($this->config, $this->root, commandLine: new Config(targets: $targets)), + ); + } + + + /** + * The layer an upgrade lays over the configuration: the command line, the plugins of the packages of upgrading data, + * the targets, the types, and where they were measured in every file the names resolved for certain with what the + * namespaces declare. + * @param list $plugins + * @param array $targets package => version + * @param array $decisions path => value, over those of the command line + */ + public function createLayer( + array $plugins, + bool $types, + array $targets = [], + ?NamespaceDeclarations $declarations = null, + array $decisions = [], + ): Config + { + $commandLine = $this->commandLine; + $certain = $declarations !== null; + $tree = $commandLine->decisions ?? []; + foreach ($decisions as $path => $value) { + $tree = Helpers::placeValue($tree, $path, $value); + } + + return new Config( + use: [...$commandLine->plugins ?? [], ...$commandLine->use ?? [], ...$plugins], + targets: $targets, + namespaces: $certain ? ['functions' => $declarations->functions, 'constants' => $declarations->constants] : [], + nameResolution: $certain ? 'certain' : null, + fixRisky: $commandLine->fixRisky ?? [], + severity: $commandLine->severity ?? [], + typeAnalysis: $types ? 'phpstan' : null, + decisions: $tree, + ); + } + + + /** + * How the names are resolved where the configuration says nothing of them: for certain with what the namespaces of + * the scope declare, or of the files of it given, unless a file cannot be read or does not parse. + * @param ?list $files relative to the root + */ + public function resolveNames(?array $files = null): NameResolution + { + if ($this->config->nameResolution !== null) { + return new NameResolution; + } + + $declarations = NamespaceDeclarations::collect($this->root, $files ?? $this->findFiles()); + return $declarations->unparsed === [] + ? new NameResolution($declarations) + : new NameResolution(warning: 'The names are not resolved for certain: ' . count($declarations->unparsed) . ' of the files cannot be read or do not parse.'); + } + + + /** + * The files of the scope of the configuration, or of the paths within it, relative to the root. + * @param list $paths all the configured ones where empty + * @return list + * @throws ConfigurationException + */ + private function findFiles(array $paths = []): array + { + $factory = new RunnerFactory; + return $factory->createFileFinder($factory->resolve($this->config, $this->root, $this->commandLine)) + ->findFiles($paths ?: $this->config->paths, configured: $this->config->paths); + } + + + /** + * The files the estimate and a run of its plan check, within the paths given or all the configured ones, + * those a generator wrote left out, which the estimate counts apart. + * @param list $paths relative to the root + * @throws ConfigurationException + */ + public function findUpgradeFiles(array $paths = []): UpgradeFiles + { + return UpgradeFiles::fromScope($this->root, $this->findFiles($paths)); + } + + + /** + * The style a rewrite keeps: the decisions the configuration file makes, the types left out, less each one the code + * violates anywhere, whatever its severity; none without a configuration file. + * @return list + * @throws ConfigurationException + */ + public function measureSatisfiedRequirements(): array + { + if ($this->configFile === null) { + return []; + } + + try { + $factory = new RunnerFactory(phpstanInstalled: false); + $resolution = $factory->resolve($this->config, $this->root, $this->commandLine); + } catch (ConfigurationException) { // a decision of the project needs the types, so the check takes them + $factory = new RunnerFactory; + $resolution = $factory->resolve($this->config, $this->root, $this->commandLine); + } + + $runner = $factory->createRunner($resolution, configFile: $this->configFile); + $collected = new CollectingReporter; + $runner->run($runner->files->findFiles($this->config->paths, configured: $this->config->paths), false, $collected); + $violated = []; + foreach ($collected->violations as $violations) { + foreach ($violations as $violation) { + $violated[$violation->decision] = true; + } + } + + $satisfied = []; + foreach ($resolution->resolvedConfig->decisions as $path => $decision) { + if ( + $decision->inactive === null + && $decision->decision->isRequirement() + && !$decision->value->isKept() + && !isset($violated[$path]) + ) { + $satisfied[] = $path; + } + } + + return $satisfied; + } + + + /** The directory of the `composer.json` of the project, the root or one above it; the root where there is none. */ + public function findComposerRoot(): string + { + return ProjectPackages::read($this->root)->rootPath ?? $this->root; + } + + + public function getCacheDir(): string + { + return RunnerFactory::resolveCacheDir($this->config, $this->root); + } +} diff --git a/src/Upgrade/Provenance.php b/src/Upgrade/Provenance.php new file mode 100644 index 0000000000..e6e20d6630 --- /dev/null +++ b/src/Upgrade/Provenance.php @@ -0,0 +1,47 @@ + package of upgrading data => its version, `php` for the data DressCode ships */ + public array $data, + ) { + } + + + /** What a plan made now is made with, the upgrading data the project and DressCode have; those of PHP travel with DressCode. */ + public static function current(ProjectPackages $project): self + { + $versions = [PackageDiscovery::Php => Application::Version]; + foreach ($project->installed as $package => $installed) { + if (isset($installed['extra']['dresscode']['upgrading-dir'])) { + $versions[$package] = $installed['version'] ?? 'dev'; + } + } + + foreach (array_keys(PackageDiscovery::findInstalledDataPackages()) as $package) { + $versions[$package] ??= InstalledVersions::getPrettyVersion($package) ?? 'dev'; + } + + ksort($versions); + return new self(Application::Version, $versions); + } +} diff --git a/src/Upgrade/Restoration.php b/src/Upgrade/Restoration.php new file mode 100644 index 0000000000..be8a8fa4a5 --- /dev/null +++ b/src/Upgrade/Restoration.php @@ -0,0 +1,28 @@ + the files written back, relative to the root, those of Composer by their names */ + public array $restored, + /** @var array the files left as they are => why */ + public array $left, + /** the packages of the lock are installed after the files of Composer went back */ + public bool $installs, + /** why `composer install` failed; null where it did not run or did not fail */ + public ?string $installFailure = null, + ) { + } +} diff --git a/src/Upgrade/RunOutcome.php b/src/Upgrade/RunOutcome.php new file mode 100644 index 0000000000..7636c03510 --- /dev/null +++ b/src/Upgrade/RunOutcome.php @@ -0,0 +1,120 @@ +>> step => topic => the files its fix changed, relative to the root */ + public array $applied = [], + /** @var array> topic => what its last fix left */ + public array $tasks = [], + /** @var list the tests failing after the step that passed before, none where they wrote no results */ + public array $newFailures = [], + /** @var array the files the step could not fix, write or write back, relative to the root => why */ + public array $unprocessed = [], + /** what refuses the target of the manual step the run stopped at */ + public ?Blockers $blockers = null, + /** @var list the files the interrupted step may have rewritten, relative to the root */ + public array $interrupted = [], + /** how the command of the tests crashed after the step though none of its tests failed */ + public ?string $crash = null, + /** the results before the first step the last tests of the run were compared with; null where none ran */ + public ?TestResults $baseline = null, + /** @var list the files of Composer a raise changed, which `upgrade abort` puts back */ + public array $raisedFiles = [], + /** @var list the files the targets still find something in that a fix changes and no step of the plan did */ + public array $remaining = [], + /** @var list the comments that put a place of the upgrade off without a reason, as `upgrade tasks` lists them */ + public array $postponed = [], + /** @var list what the run tells the user, an analysis that went without or upgrading data it lacked among it */ + public array $warnings = [], + /** why Composer could not raise the constraints of the step the run stopped at; null where it was not asked or did */ + public ?string $composerFailure = null, + /** why what the step changed is left uncommitted; null where it was committed or was not to be */ + public ?string $commitFailure = null, + ) { + } + + + /** + * 0 when the run is done, 1 when it leaves work for a human (tasks, tests that newly fail, what no step fixed, an + * interrupted run to put back or discard), 2 when it is incomplete, as files went unprocessed, a commit was refused or + * the tests do not run before the first step, 4 when it waits for a step outside DressCode. + */ + public function getExitCode(): int + { + return match ($this->stop) { + RunStop::Done => 0, + RunStop::Tests => $this->step === null && $this->crash !== null ? 2 : 1, + RunStop::Tasks, RunStop::Remaining, RunStop::Interrupted => 1, + RunStop::Unprocessed, RunStop::Commit => 2, + RunStop::Composer, RunStop::Raised, RunStop::Manual => 4, + }; + } + + + /** @param list $warnings */ + public function withWarnings(array $warnings): self + { + return new self(...['warnings' => array_values(array_unique([...$this->warnings, ...$warnings]))] + get_object_vars($this)); + } + + + /** + * A run stopped after a raise as the run that goes on after it reads it in another process. + * @param array $data as `toArray()` made it + */ + public static function fromArray(array $data): self + { + $applied = $tasks = []; + foreach (is_array($data['applied'] ?? null) ? $data['applied'] : [] as $step => $topics) { + foreach (is_array($topics) ? $topics : [] as $topic => $files) { + $applied[(int) $step][(string) $topic] = array_values(array_map(strval(...), (array) $files)); + } + } + + foreach (is_array($data['tasks'] ?? null) ? $data['tasks'] : [] as $topic => $list) { + $paths = array_map(strval(...), array_column((array) $list, 'path')); + $violations = WorkerCodec::decodeViolations(array_column((array) $list, 'violation')); + $tasks[(string) $topic] = array_map(fn(string $path, $violation) => new Task($path, $violation), $paths, $violations); + } + + return new self(RunStop::Raised, is_int($data['step'] ?? null) ? $data['step'] : null, $applied, $tasks); + } + + + /** + * What a run that goes on after the raise needs of this one: where it stopped, what the fixes changed and what they + * left. + * @return array + */ + public function toArray(): array + { + return [ + 'step' => $this->step, + 'applied' => $this->applied, + 'tasks' => array_map(fn(array $list) => array_map(fn(Task $task) => [ + 'path' => $task->path, + 'violation' => WorkerCodec::encodeViolation($task->violation), + ], $list), $this->tasks), + ]; + } +} diff --git a/src/Upgrade/RunState.php b/src/Upgrade/RunState.php new file mode 100644 index 0000000000..f427549923 --- /dev/null +++ b/src/Upgrade/RunState.php @@ -0,0 +1,172 @@ +root)->findDirectory(); + $name = substr(hash('xxh128', $project->root), 0, 16); + $this->directory = $gitDir === null ? $project->getCacheDir() . "/upgrade/$name" : "$gitDir/dresscode-upgrade/$name"; + $this->durable = $gitDir !== null; + $this->snapshots = new Snapshots("$this->directory/snapshots"); + $this->journal = new ComposerJournal("$this->directory/composer"); + } + + + /** + * Does the work holding the lock of the project, which another run of the plan or a plan being saved holds meanwhile. + * @template T + * @param \Closure(): T $work + * @return T + * @throws ConfigurationException where another process holds it + */ + public function runExclusively(\Closure $work): mixed + { + FileSystem::createDir(dirname($this->directory)); + $handle = FileSystem::open("$this->directory.lock", 'c'); + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + throw new ConfigurationException('Another run of the upgrade works in the project right now; wait until it ends.'); + } + + try { + return $work(); + } finally { + fclose($handle); + } + } + + + /** + * Puts back what a run keeps to go back, holding the lock of the project: the files of the steps it did not finish + * and those of Composer a raise changed, the packages of the lock installed then; a dry run only says what it would. + * @param ?\Closure(string): void $onProgress told what is being done + * @throws ConfigurationException where another process holds the lock + */ + public function restore(Project $project, PackageManager $composer, bool $dryRun = false, ?\Closure $onProgress = null): Restoration + { + return $this->runExclusively(function () use ($project, $composer, $dryRun, $onProgress): Restoration { + $restored = $left = []; + foreach ($this->snapshots->findInterrupted() as $step => $files) { + $left += $this->snapshots->restore($step, $project->root, $dryRun); + $restored = [...$restored, ...array_diff($files, array_keys($left))]; + } + + // the journal is forgotten only once nothing is left of it and what it put back is installed, so that a + // conflict or a failed install can be gone back over + $composerRoot = $project->findComposerRoot(); + [$composerFiles, $composerLeft] = $this->journal->restore($composerRoot, $dryRun); + $installs = $dryRun + ? ($composerFiles !== [] || $this->journal->isAwaitingInstall()) && is_file("$composerRoot/composer.lock") + : $this->journal->isAwaitingInstall(); + $failure = null; + if ($installs && !$dryRun) { + $onProgress?->__invoke('Composer, installing the packages of the lock'); + try { + $composer->install(); + $this->journal->markInstalled(); + } catch (ComposerException $e) { + $failure = $e->getMessage(); + } + } + + if (!$dryRun && $composerLeft === [] && !$this->journal->isAwaitingInstall()) { + $this->journal->forget(); + } + + return new Restoration([...$restored, ...$composerFiles], $left + $composerLeft, $installs, $failure); + }); + } + + + /** The results the command of the tests gave before the first step; null where it has not run yet. */ + public function findBaseline(string $command): ?TestResults + { + return TestResults::fromFile($this->getBaselineFile($command)); + } + + + public function saveBaseline(string $command, TestResults $results): void + { + $results->save($this->getBaselineFile($command)); + } + + + public function findUncommitted(): ?UncommittedStep + { + $file = "$this->directory/uncommitted.json"; + $data = is_file($file) ? json_decode(FileSystem::read($file), associative: true) : null; + if (!is_array($data) || !is_int($data['step'] ?? null)) { + return null; + } + + $strings = fn(mixed $list) => array_values(array_filter((array) $list, is_string(...))); + $topics = []; + foreach ((array) ($data['topics'] ?? []) as $topic => $paths) { + $topics[(string) $topic] = $strings($paths); + } + + $hashes = array_filter((array) ($data['hashes'] ?? []), is_string(...)); + return new UncommittedStep( + $data['step'], + is_string($data['identity'] ?? null) ? $data['identity'] : '', + $topics, + $strings($data['unprocessed'] ?? []), + array_combine(array_map(strval(...), array_keys($hashes)), $hashes), + ); + } + + + public function saveUncommitted(UncommittedStep $step): void + { + Helpers::replaceFile("$this->directory/uncommitted.json", json_encode((array) $step, JSON_THROW_ON_ERROR)); + } + + + public function forgetUncommitted(): void + { + FileSystem::delete("$this->directory/uncommitted.json"); + } + + + /** Forgets everything, once the plan is done or another one is saved. */ + public function remove(): void + { + FileSystem::delete($this->directory); + } + + + private function getBaselineFile(string $command): string + { + return "$this->directory/tests-baseline-" . substr(hash('xxh128', $command), 0, 16) . '.json'; + } +} diff --git a/src/Upgrade/Snapshots.php b/src/Upgrade/Snapshots.php new file mode 100644 index 0000000000..b52bdd5af4 --- /dev/null +++ b/src/Upgrade/Snapshots.php @@ -0,0 +1,130 @@ +readIndex($step) ?? throw $this->createUnreadable($step); + if (!isset($index[$path])) { + $index[$path] = ['name' => hash('xxh128', $path)]; + Helpers::replaceFile("$this->dir/$step/" . $index[$path]['name'], $before); + } + + $index[$path]['written'] = hash('xxh128', $after); + Helpers::replaceFile("$this->dir/$step/index.json", json_encode($index, JSON_THROW_ON_ERROR)); + } + + + /** + * Writes the files of the step back as they were before it and forgets them, but those changed since the step wrote + * them, which are left as they are and whose text before the step stays kept; a file still as it was before, which + * a run interrupted before writing it leaves, is only forgotten. A dry run only says which files it would leave. + * @return array the files left as they are, relative to the root => why + */ + public function restore(int $step, string $root, bool $dryRun = false): array + { + $index = $this->readIndex($step) ?? throw $this->createUnreadable($step); + $left = []; + foreach ($index as $path => ['name' => $name, 'written' => $written]) { + $file = Helpers::toAbsolutePath($path, $root); + $current = @file_get_contents($file); // @ a file deleted since is changed too + $before = FileSystem::read("$this->dir/$step/$name"); + if ($current !== $before && hash('xxh128', (string) $current) !== $written) { + $left[$path] = "The file changed after step $step wrote it, so it is not written back; its text before the step is kept in `$this->dir/$step/$name`."; + } elseif ($dryRun) { + continue; + } elseif ($current !== $before && !Helpers::writeFile($file, $before)) { + $left[$path] = "The file cannot be written, so it is not written back; its text before the step is kept in `$this->dir/$step/$name`."; + } else { + FileSystem::delete("$this->dir/$step/$name"); + unset($index[$path]); + } + } + + if ($dryRun) { + return $left; + } elseif ($index === []) { + $this->drop($step); + } else { + Helpers::replaceFile("$this->dir/$step/index.json", json_encode($index, JSON_THROW_ON_ERROR)); + } + + return $left; + } + + + /** Forgets the files of the step, once it passed its tests. */ + public function drop(int $step): void + { + FileSystem::delete("$this->dir/$step"); + } + + + /** + * The files of the steps a run left without passing or going back entirely, as one interrupted does; none where the + * snapshots of the step cannot be read. + * @return array> step => its files relative to the root + */ + public function findInterrupted(): array + { + $steps = []; + foreach (glob("$this->dir/*/index.json") ?: [] as $file) { + $step = (int) basename(dirname($file)); + $steps[$step] = array_keys($this->readIndex($step) ?? []); + } + + ksort($steps); + return $steps; + } + + + /** @return ?array file relative to the root => its snapshot; null where unreadable */ + private function readIndex(int $step): ?array + { + $file = "$this->dir/$step/index.json"; + if (!is_file($file)) { + return []; + } + + $index = json_decode((string) @file_get_contents($file), associative: true); // @ the failure is told by the result + return is_array($index) + && array_all( + $index, + fn($entry) => is_array($entry) && is_string($entry['name'] ?? null) && is_string($entry['written'] ?? null), + ) + ? $index + : null; + } + + + private function createUnreadable(int $step): \RuntimeException + { + return new \RuntimeException("The snapshots of step $step in `$this->dir/$step` cannot be read; take the files of the step back with git, or `dresscode upgrade run --discard` drops them."); + } +} diff --git a/src/Upgrade/Step.php b/src/Upgrade/Step.php new file mode 100644 index 0000000000..f41bbccb1b --- /dev/null +++ b/src/Upgrade/Step.php @@ -0,0 +1,21 @@ +> topic => the files it wrote => their text */ + public array $written, + /** @var array the files the step could not process => why */ + public array $unprocessed, + /** @var array> topic => its manual tasks */ + public array $tasks, + /** @var list */ + public array $warnings, + ) { + } +} diff --git a/src/Upgrade/Target.php b/src/Upgrade/Target.php new file mode 100644 index 0000000000..7aeb74f608 --- /dev/null +++ b/src/Upgrade/Target.php @@ -0,0 +1,56 @@ + $targets + * @return array package => version + */ + public static function collectVersions(array $targets): array + { + return array_combine(array_column($targets, 'package'), array_column($targets, 'version')); + } + + + /** The target as a message says it, `php 8.4`, `nette/application 4.0`. */ + public function format(): string + { + return "$this->package $this->version"; + } +} diff --git a/src/Upgrade/Task.php b/src/Upgrade/Task.php new file mode 100644 index 0000000000..996317ccdb --- /dev/null +++ b/src/Upgrade/Task.php @@ -0,0 +1,26 @@ + the tests failing newly, none where the command gave no results */ + public array $newFailures, + /** how the command crashed */ + public ?string $crash, + ) { + } +} diff --git a/src/Upgrade/TestResults.php b/src/Upgrade/TestResults.php new file mode 100644 index 0000000000..cbfb05561f --- /dev/null +++ b/src/Upgrade/TestResults.php @@ -0,0 +1,121 @@ + the identity of a test => how it ended */ + public array $tests, + /** why the results are not to be trusted: the command crashed, did not end in time or wrote no report of tests */ + public ?string $crash = null, + /** when the tests ran, a Unix timestamp; null for results read from a report */ + public ?int $time = null, + ) { + } + + + /** Whether the command writes its results where the JUnit file it is given stands. */ + public static function hasJunitPlaceholder(string $command): bool + { + return str_contains($command, self::Placeholder); + } + + + /** + * The results the JUnit report holds, a test repeated under one identity passing only where every one passed; null + * for a text that is no report. + */ + public static function fromJunit(string $xml, string $root): ?self + { + $document = @simplexml_load_string($xml); // @ a broken file is no report + if ($document === false || !in_array($document->getName(), ['testsuites', 'testsuite'], true)) { + return null; + } + + $tests = []; + $rank = array_flip(array_column(TestStatus::cases(), 'value')); + foreach ($document->xpath('//testcase') ?: [] as $case) { + $class = str_replace(['\\', strtr($root, '\\', '/') . '/'], ['/', ''], (string) $case['classname']); + $id = "$class::{$case['name']}"; + $status = match (true) { + isset($case->error) => TestStatus::Error, + isset($case->failure) => TestStatus::Failed, + isset($case->skipped) => TestStatus::Skipped, + default => TestStatus::Passed, + }; + $tests[$id] = isset($tests[$id]) && $rank[$tests[$id]->value] > $rank[$status->value] ? $tests[$id] : $status; + } + + return new self($tests); + } + + + /** The results a run kept; null where there are none, or of a shape no longer read. */ + public static function fromFile(string $file): ?self + { + $tests = is_file($file) ? json_decode(FileSystem::read($file), associative: true) : null; + $tests = is_array($tests) ? array_map(fn($status) => is_string($status) ? TestStatus::tryFrom($status) : null, $tests) : [null]; + return in_array(null, $tests, true) ? null : new self($tests, time: (int) filemtime($file)); + } + + + public function save(string $file): void + { + Helpers::replaceFile( + $file, + json_encode(array_map(fn(TestStatus $status) => $status->value, $this->tests), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), + ); + } + + + /** + * The tests not passing now that passed in the baseline or were not there, each said how it ended where it did not + * just fail, and those of the baseline that passed and are gone. + * @return list + */ + public function findNewFailures(self $baseline): array + { + $failures = []; + foreach ($this->tests as $test => $status) { + if ($status !== TestStatus::Passed && ($baseline->tests[$test] ?? TestStatus::Passed) === TestStatus::Passed) { + $failures[] = $test . ($status === TestStatus::Failed ? '' : " ($status->value)"); + } + } + + $passed = array_filter($baseline->tests, fn(TestStatus $status) => $status === TestStatus::Passed); + foreach (array_keys(array_diff_key($passed, $this->tests)) as $test) { + $failures[] = "$test (gone)"; + } + + return $failures; + } + + + /** How many of the tests did not pass: failed, threw or were skipped. */ + public function countNotPassed(): int + { + return count(array_filter($this->tests, fn(TestStatus $status) => $status !== TestStatus::Passed)); + } +} diff --git a/src/Upgrade/TestRunner.php b/src/Upgrade/TestRunner.php new file mode 100644 index 0000000000..104693eca0 --- /dev/null +++ b/src/Upgrade/TestRunner.php @@ -0,0 +1,19 @@ +, int> the places by what the run can do about them, every case in its order */ + public array $places, + /** what its fixes are right on: the constraint of today for the bridge, the raised one for the jump; null where unknown */ + public ?string $constraint = null, + ) { + } + + + /** The places, or those the run can do the given thing about. */ + public function countPlaces(?Fixability $fixability = null): int + { + return $fixability === null ? array_sum($this->places) : $this->places[$fixability->value]; + } +} diff --git a/src/Upgrade/TopicReporter.php b/src/Upgrade/TopicReporter.php new file mode 100644 index 0000000000..599304b7d4 --- /dev/null +++ b/src/Upgrade/TopicReporter.php @@ -0,0 +1,64 @@ + the files the fix changes, relative to the root => the text before and after */ + public private(set) array $changed = []; + + /** @var list */ + public private(set) array $tasks = []; + + /** @var array the files the fix could not process, relative to the root => why */ + public private(set) array $unprocessed = []; + + + public function __construct( + /** @var list what the configuration of the fix and its run tell the user, the analyses that went without among it */ + public private(set) array $warnings = [], + ) { + } + + + public function start(RunInfo $run): void + { + } + + + public function reportFile(FileResult $result): void + { + if ($result->syntaxError !== null || $result->failure !== null) { + $this->unprocessed[$result->path] = $result->failure ?? "Syntax error: $result->syntaxError"; + return; + } elseif ($result->changed && $result->output !== null) { + $this->changed[$result->path] = [$result->code, $result->output]; + } + + foreach ($result->remaining as $violation) { + if (!$violation->fixable || $violation->refused) { + $this->tasks[] = new Task($result->path, $violation); + } + } + } + + + public function finish(RunResult $result): void + { + $this->warnings = array_values(array_unique([...$this->warnings, ...$result->warnings])); + } +} diff --git a/src/Upgrade/UncommittedStep.php b/src/Upgrade/UncommittedStep.php new file mode 100644 index 0000000000..ef0b7157c6 --- /dev/null +++ b/src/Upgrade/UncommittedStep.php @@ -0,0 +1,52 @@ +> topic => the files its fix wrote, relative to the root */ + public array $topics, + /** @var list the files the step could not process, relative to the root */ + public array $unprocessed, + /** @var array a file the step wrote => the hash of the text it wrote last */ + public array $hashes = [], + ) { + } + + + /** What the step does, its title and whether it is left out aside. */ + public static function identify(Step $step): string + { + return hash('xxh128', serialize([$step::class, array_diff_key(get_object_vars($step), ['title' => true, 'skip' => true])])); + } + + + /** + * The files whose changes are the step's: those it could not process, which the user is to change, and those it wrote + * that nobody changed since; a file it wrote that changed is a change of the working tree of its own. + * @return list + */ + public function listOwnFiles(string $root): array + { + $written = array_filter( + array_unique(array_merge(...array_values($this->topics))), + fn(string $path) => hash('xxh128', (string) @file_get_contents("$root/$path")) === ($this->hashes[$path] ?? null), // @ a file deleted since is changed + ); + return [...array_values($written), ...$this->unprocessed]; + } +} diff --git a/src/Upgrade/UpdateStep.php b/src/Upgrade/UpdateStep.php new file mode 100644 index 0000000000..a3d5aa4f5d --- /dev/null +++ b/src/Upgrade/UpdateStep.php @@ -0,0 +1,29 @@ + the names the check is narrowed to, null for none, an empty list running nothing */ + public ?array $only, + /** @var list the sets laid over the presets the configuration uses and beneath its own decisions */ + public array $laid, + /** the scope of a project without a configuration file, which a worker would not find; null for one with it */ + public ?Config $scope = null, + ) { + } + + + /** @param array{layer: array, only: ?list, laid: list, scope: ?array{paths: list, excludePaths: list}} $data as `toArray()` gave it */ + private static function fromArray(array $data): self + { + return new self( + new Config(...$data['layer']), + $data['only'], + $data['laid'], + $data['scope'] === null ? null : new Config(...$data['scope']), + ); + } + + + public static function read(string $file): self + { + return self::fromArray(json_decode(FileSystem::read($file), associative: true, flags: JSON_THROW_ON_ERROR)); + } + + + /** + * The project of the check, without the types where the layer asks for none. + * @return array{RunnerFactory, ResolvedProject} + * @throws ConfigurationException + */ + public function resolve(Config $config, string $root): array + { + $factory = new RunnerFactory(withoutTypes: $this->layer->typeAnalysis === null); + return [$factory, $factory->resolve($this->scope ?? $config, $root, $this->layer, $this->only, laid: $this->laid)]; + } + + + /** @return array{layer: array, only: ?list, laid: list, scope: ?array{paths: list, excludePaths: list}} */ + public function toArray(): array + { + $layer = $this->layer; + return [ + 'layer' => [ + 'use' => [...$layer->plugins, ...$layer->use], + 'targets' => $layer->targets, + 'namespaces' => $layer->namespaces, + 'nameResolution' => $layer->nameResolution, + 'fixRisky' => $layer->fixRisky, + 'severity' => $layer->severity, + 'typeAnalysis' => $layer->typeAnalysis, + 'decisions' => $layer->decisions, + ], + 'only' => $this->only, + 'laid' => $this->laid, + 'scope' => $this->scope === null ? null : ['paths' => $this->scope->paths, 'excludePaths' => $this->scope->excludePaths], + ]; + } + + + public function write(string $file): void + { + FileSystem::write($file, json_encode($this->toArray(), JSON_THROW_ON_ERROR)); + } +} diff --git a/src/Upgrade/UpgradeFiles.php b/src/Upgrade/UpgradeFiles.php new file mode 100644 index 0000000000..a75ae3ad1e --- /dev/null +++ b/src/Upgrade/UpgradeFiles.php @@ -0,0 +1,38 @@ + relative to the root */ + public array $files, + /** @var list relative to the root */ + public array $generated, + ) { + } + + + /** @param list $files the scope, relative to the root, or absolute when outside it */ + public static function fromScope(string $root, array $files): self + { + $generated = array_values(array_filter( + $files, + fn(string $file) => FileSample::isGenerated(Helpers::toAbsolutePath($file, $root)), + )); + return new self(array_values(array_diff($files, $generated)), $generated); + } +} diff --git a/src/Upgrade/VersionControl.php b/src/Upgrade/VersionControl.php new file mode 100644 index 0000000000..66445bb18c --- /dev/null +++ b/src/Upgrade/VersionControl.php @@ -0,0 +1,38 @@ + $except relative to the root + */ + function readStatus(array $except = []): WorkingTree; + + /** + * The files of those given that are tracked or would be added, the ignored ones aside. + * @param list $paths relative to the root + * @return list + * @throws GitException + */ + function listUnignored(array $paths): array; + + /** + * Commits the files with the texts given, the working tree staying as it is. + * @param array $files relative to the root => text + * @throws GitException + */ + function commit(array $files, string $subject, string $body): void; +} diff --git a/src/Upgrade/enums.php b/src/Upgrade/enums.php new file mode 100644 index 0000000000..827df37d0a --- /dev/null +++ b/src/Upgrade/enums.php @@ -0,0 +1,142 @@ +fixable, $violation->refused && $violation->risk === Risk::BehaviorChanges => self::Manual, + $violation->refused && $violation->risk === Risk::TypeUnknown => self::TypeUnknown, + $violation->refused && $violation->risk === Risk::NameUncertain => self::NameUncertain, + default => self::Automatic, + }; + } +} diff --git a/src/Upgrade/exceptions.php b/src/Upgrade/exceptions.php new file mode 100644 index 0000000000..d2651b8093 --- /dev/null +++ b/src/Upgrade/exceptions.php @@ -0,0 +1,35 @@ +readMap($path), $convert, fn(string $key) => $values->getEntryDecision($path, $key)); + return self::build( + $values->readMap($path), + $convert, + fn(string $key) => $values->getEntryDecision($path, $key), + fn(string $key) => $values->getEntryOrigin($path, $key), + ); } @@ -62,9 +68,10 @@ public static function fromEntries(array $entries, \Closure $convert, string $de * @param array $entries * @param ?\Closure(mixed, MemberPattern, string): V $convert * @param \Closure(string): string $decide the key as written => the decision its entry is reported under + * @param ?\Closure(string): ?Layer $findOrigin the key as written => the section of the upgrading data that laid it * @return self */ - private static function build(array $entries, ?\Closure $convert, \Closure $decide): self + private static function build(array $entries, ?\Closure $convert, \Closure $decide, ?\Closure $findOrigin = null): self { $byName = []; foreach ($entries as $key => $value) { @@ -77,6 +84,7 @@ private static function build(array $entries, ?\Closure $convert, \Closure $deci $pattern, $convert === null ? $value : $convert($value, $pattern, (string) $key), $decide((string) $key), + $findOrigin === null ? null : $findOrigin((string) $key), ); } diff --git a/src/Upgrading/MemberMapEntry.php b/src/Upgrading/MemberMapEntry.php index 8c435ef65e..92ad6fc580 100644 --- a/src/Upgrading/MemberMapEntry.php +++ b/src/Upgrading/MemberMapEntry.php @@ -7,10 +7,12 @@ namespace DressCode\Upgrading; +use DressCode\Config\Layer; + /** - * An entry of a map of members: its key, what the rule keeps of its value, and the decision a use of it is reported - * under, the map itself or the requirement that laid the upgrading data holding it. + * An entry of a map of members: its key, what the rule keeps of its value, the decision a use of it is reported + * under, the map itself or the requirement that laid the upgrading data holding it, and the section of those data. * @template T */ final readonly class MemberMapEntry @@ -21,6 +23,8 @@ public function __construct( /** @var T */ public mixed $value, public string $decision, + /** the section of the upgrading data of a package that laid the entry, null where the project wrote it */ + public ?Layer $origin = null, ) { } } diff --git a/src/Values.php b/src/Values.php index b5dc621a87..37a92c6b00 100644 --- a/src/Values.php +++ b/src/Values.php @@ -96,27 +96,21 @@ public function findWithdrawnNames(string $path): array /** - * The keys of the entries of a map the upgrading data of a package laid, not a layer of the project. - * @return array + * The decision an entry of a map is reported under: the requirement that laid the upgrading data holding it, the + * map itself otherwise. */ - public function findPackageEntries(string $path): array + public function getEntryDecision(string $path, string $key): string { - $value = $this->get($path); - return $value->content === null || $value->isKept() - ? [] - : array_map(fn() => true, array_filter($value->getEntries(), fn(Value $entry) => $entry->origin?->kind === LayerKind::Package)); + return $this->getEntryOrigin($path, $key)->decision ?? $path; } - /** - * The decision an entry of a map is reported under: the requirement that laid the upgrading data holding it, the - * map itself otherwise. - */ - public function getEntryDecision(string $path, string $key): string + /** The section of the upgrading data of a package that laid the entry of a map, null where the project wrote it. */ + public function getEntryOrigin(string $path, string $key): ?Config\Layer { $value = $this->get($path); $origin = $value->content === null || $value->isKept() ? null : ($value->getEntries()[$key] ?? null)?->origin; - return $origin->decision ?? $path; + return $origin?->kind === LayerKind::Package ? $origin : null; } diff --git a/src/Violation.php b/src/Violation.php index abc7a740aa..6b7b11b077 100644 --- a/src/Violation.php +++ b/src/Violation.php @@ -59,6 +59,11 @@ public function __construct( * leaves too, code a fix wrote taking the lines of the code beside it; null where the fixed text was read anew */ public ?array $originalLines = null, + /** + * the section of the upgrading data of a package whose entry the occurrence is of, null where the project wrote + * the entry or the rule reads no map + */ + public ?Config\Layer $source = null, ) { if ($risk === null && ($refused || $because !== null)) { throw new \InvalidArgumentException("Violation of `$decision`: `refused` and `because` belong to a risky violation, but no risk is given."); diff --git a/tests/DressCode/Config/ConfigResolver.phpt b/tests/DressCode/Config/ConfigResolver.phpt index dc090023ba..7043c1b316 100644 --- a/tests/DressCode/Config/ConfigResolver.phpt +++ b/tests/DressCode/Config/ConfigResolver.phpt @@ -613,6 +613,19 @@ test('a severity holds for a value said by its layer or one below it, the most s }); +test('an unknown key is looked up in the tables of the translator of the run, as an unknown name is', function () { + $resolver = new ConfigResolver(new PluginRegistry(new DressCode\Interop\Translator( + ['acme_spacing' => ['spacing.call' => 'compact']], + [], + ))); + Assert::exception( + fn() => $resolver->resolve(new Config(decisions: ['acme_spacing' => true]), '8.3'), + ConfigurationException::class, + 'Key `acme_spacing` is unknown. It is the name of a rule of another tool, covered by `spacing.call`;%a%', + ); +}); + + test('a rule an override turns on runs somewhere, however the override decides it', function () { // a word on a structure decides every decision under it $config = new Config( @@ -750,7 +763,7 @@ test('the version of PHP a profile says is the target of its files, one older th test('what the namespaces declare adds up over the layers, and only the configuration makes it certain', function () { $declaring = [new Config\UpgradingData( - new Config\Layer(Config\LayerKind::Package, 'upgrading.neon', 'fw/config'), + new Config\Layer(Config\LayerKind::Package, package: 'fw/config'), 'fw/config', new Profile(namespaces: ['functions' => ['Fw\Config\{service, param}'], 'constants' => ['Fw\VERSION']])->namespaces, [], @@ -763,13 +776,13 @@ test('what the namespaces declare adds up over the layers, and only the configur ); Assert::same( [ - 'Fw\Config\service' => 'upgrading.neon of fw/config', - 'Fw\Config\param' => 'upgrading.neon of fw/config', + 'Fw\Config\service' => 'DressCode for fw/config', + 'Fw\Config\param' => 'DressCode for fw/config', 'App\helper' => 'the configuration', ], $uncertain->namespacedFunctions, ); - Assert::same(['Fw\VERSION' => 'upgrading.neon of fw/config', 'Fw\version' => 'the configuration'], $uncertain->namespacedConstants); + Assert::same(['Fw\VERSION' => 'DressCode for fw/config', 'Fw\version' => 'the configuration'], $uncertain->namespacedConstants); Assert::same('uncertain', $uncertain->nameResolution); Assert::false($uncertain->toNamespacedSymbols()->complete); Assert::true($uncertain->toNamespacedSymbols()->hasFunction('App\helper')); diff --git a/tests/DressCode/Config/DataPackageCatalogue.phpt b/tests/DressCode/Config/DataPackageCatalogue.phpt new file mode 100644 index 0000000000..d80e6a674e --- /dev/null +++ b/tests/DressCode/Config/DataPackageCatalogue.phpt @@ -0,0 +1,14 @@ + ['nette/', 'latte/latte']], DataPackageCatalogue::load($dir)); +}); diff --git a/tests/DressCode/Config/DecisionResolver.phpt b/tests/DressCode/Config/DecisionResolver.phpt index b19a73c056..b59bb8d73e 100644 --- a/tests/DressCode/Config/DecisionResolver.phpt +++ b/tests/DressCode/Config/DecisionResolver.phpt @@ -125,6 +125,11 @@ test('a path the catalogue does not know is named with the nearest known one', f ConfigurationException::class, 'Key `spacng` is unknown; write `spacing`.', ); + Assert::exception( + fn() => $resolver->resolve([[new Layer(LayerKind::Configuration), ['native_function_invocation' => true]]]), + ConfigurationException::class, + 'Key `native_function_invocation` is unknown. It is the name of a rule of another tool, covered by %a%; `dresscode migrate config` translates a configuration of another tool.', + ); Assert::exception( fn() => $resolver->resolve([[new Layer(LayerKind::Configuration), ['qualification' => 'bare']]]), ConfigurationException::class, diff --git a/tests/DressCode/Config/Draft.phpt b/tests/DressCode/Config/Draft.phpt new file mode 100644 index 0000000000..191e350bc4 --- /dev/null +++ b/tests/DressCode/Config/Draft.phpt @@ -0,0 +1,108 @@ +setUse(['psr12'], 'the standard') + ->setScope(['src', 'tests'], ['tests/fixtures' => 'test data'], ['php', 'phpt']) + ->setNamespaces(['App\a'], [], 'what the namespaces declare') + ->setTypeAnalysis('PHPStan is installed') + ->setDecision('multiline.trailingComma.argument', 'required', 'your code') + ->setDecision('multiline.trailingComma.array', 'keep', 'not checked') + ->setDecision('indentation.unit', 'tab') + ->addOverride(new Override(['src/drivers'], new Profile(decisions: ['naming' => ['method' => 'keep']])), 'left out'); + + +test('as NEON every key stands with what it rests on, the decisions in the order of the catalogue', function () use ($draft) { + // the line endings Draft writes, whatever the checkout gave the heredoc + Assert::same(str_replace("\r\n", "\n", <<<'XX' + # made to measure + # by hand + use: psr12 # the standard + + paths: [src, tests] + excludePaths: + - tests/fixtures # test data + fileExtensions: [php, phpt] + + # what the namespaces declare + nameResolution: certain + namespaces: + functions: + - App\a + + typeAnalysis: phpstan # PHPStan is installed + + indentation: + unit: tab + + multiline: + trailingComma: + array: keep # not checked + argument: required # your code + + overrides: + # left out + - + paths: + - src/drivers + naming: + method: keep + + XX), $draft->toNeon()); + + $file = createTempDir('draft') . '/dresscode.neon'; + file_put_contents($file, $draft->toNeon()); + Assert::same('keep', Loader::loadFile($file)->overrides[0]->profile->decisions['naming']['method'] ?? null); +}); + + +test('as PHP and as data it says the same without the comments', function () use ($draft) { + $file = createTempDir('draft') . '/dresscode.php'; + file_put_contents($file, $draft->toPhp()); + $config = Loader::loadFile($file); + Assert::same(['psr12'], $config->use); + Assert::same(['src', 'tests'], $config->paths); + Assert::same(['src/drivers'], $config->overrides[0]->paths); + Assert::same(['unit' => 'tab'], $config->decisions['indentation'] ?? null); + + Assert::same(['App\a'], $draft->toArray()['namespaces']['functions'] ?? null); + Assert::same([['paths' => ['src/drivers'], 'naming' => ['method' => 'keep']]], $draft->toArray()['overrides'] ?? null); + Assert::same("use: []\n", (new Draft)->toNeon()); +}); + + +test('what a run makes of the configuration written is checked against the draft', function () { + $root = createTempDir('draft-check'); + $draft = new Draft()->setUse(['psr12'])->setDecision('indentation.unit', 'tab'); + Assert::noError(fn() => $draft->renderChecked($root)); + Assert::noError(fn() => $draft->renderChecked($root, 'php')); + + // a path NEON would read otherwise is quoted, and the scope it loads as is checked too + $paths = ['src/a,b.php', 'a #b', '[x]', "it's", 'a b', 'yes', '123']; + $draft = new Draft() + ->setUse(['psr12']) + ->setScope($paths, ['src/a,c.php' => 'generated'], ['php', 'null']) + ->addOverride(new Override(['lib/a,b'], new Profile(decisions: ['naming' => ['method' => 'keep']]))); + Assert::noError(fn() => $draft->renderChecked($root)); + $file = "$root/dresscode.neon"; + file_put_contents($file, $draft->toNeon()); + $config = Loader::loadFile($file); + Assert::same($paths, $config->paths); + Assert::same(['php', 'null'], $config->fileExtensions); + Assert::contains('src/a,c.php', $config->excludePaths); + Assert::same(['lib/a,b'], $config->overrides[0]->paths); + + $resolved = new RunnerFactory()->resolve(new Config(use: ['psr12']), $root)->resolvedConfig; + $draft = new Draft()->setUse(['psr12'])->setDecision('indentation.unit', 'tab'); + Assert::exception( + fn() => $draft->checkResolution($resolved), + LogicException::class, + 'The configuration written does not say what it was made to: `indentation.unit` resolves to another value.', + ); +}); diff --git a/tests/DressCode/Config/Loader.phpt b/tests/DressCode/Config/Loader.phpt index 4ebb7baca8..e172aa7197 100644 --- a/tests/DressCode/Config/Loader.phpt +++ b/tests/DressCode/Config/Loader.phpt @@ -108,6 +108,15 @@ test('load: an explicit file is taken wherever the run started', function () use }); +test('load: a root given wins over the directory of the file, and the paths of the file are relative to it', function () use ($fixtures) { + $file = createTempDir('loader-root') . '/probe.neon'; + file_put_contents($file, "paths: [src]\n"); + [$config, $root] = Loader::load($file, sys_get_temp_dir(), root: "$fixtures/project"); + Assert::same("$fixtures/project", $root); + Assert::same(['src'], $config->paths); +}); + + test('load: without a file the default applies and the directory is the root', function () { $dir = sys_get_temp_dir(); $default = new Config(use: ['from/default']); @@ -132,7 +141,7 @@ test('load: without a file and without a default there is no style to run', func Assert::exception( fn() => Loader::load(null, sys_get_temp_dir()), ConfigurationException::class, - 'No `dresscode.neon` or `dresscode.php` found in `%a%` or above it, so there is no dress code to check against. Name a standard with `--use`.', + 'No `dresscode.neon` or `dresscode.php` found in `%a%` or above it, so there is no dress code to check against. Run `dresscode init` to have one made to measure from the code, or name a standard with `--use`.', ); }); diff --git a/tests/DressCode/Config/PackageDiscovery.phpt b/tests/DressCode/Config/PackageDiscovery.phpt index ac516fa651..63a1932856 100644 --- a/tests/DressCode/Config/PackageDiscovery.phpt +++ b/tests/DressCode/Config/PackageDiscovery.phpt @@ -190,16 +190,19 @@ test('a section above the lowest version is reported where the constraint reache // a version earlier than its section says that one Assert::same( [ - 'replacedClasses' => ['Acme\Lib\Old' => 'Acme\Lib\Renamed'], - 'replacedFunctions' => ['Acme\Lib\old_helper' => 'Acme\Lib\helper'], - 'forbiddenClasses' => ['Acme\Lib\Gone' => 'since acme/lib 4.0'], - 'forbiddenMembers' => [ - 'Acme\Lib\Order::pay(true)' => 'replaced, since acme/lib 4.0', - 'Acme\Lib\Order::flush()' => 'does nothing, since acme/lib 3.5', - ], - 'forbiddenFunctions' => ['Acme\Lib\format' => 'replaced by `Acme\Lib\render($value)`, since acme/lib 4.0'], + ['3.0', null, ['replacedClasses' => ['Acme\Lib\Old' => 'Acme\Lib\Renamed']]], + ['4.0', '3.0', ['replacedFunctions' => ['Acme\Lib\old_helper' => 'Acme\Lib\helper']]], + ['4.0', null, [ + 'forbiddenClasses' => ['Acme\Lib\Gone' => 'since acme/lib 4.0'], + 'forbiddenMembers' => ['Acme\Lib\Order::pay(true)' => 'replaced, since acme/lib 4.0'], + 'forbiddenFunctions' => ['Acme\Lib\format' => 'replaced by `Acme\Lib\render($value)`, since acme/lib 4.0'], + ]], + ['4.0', '3.5', ['forbiddenMembers' => ['Acme\Lib\Order::flush()' => 'does nothing, since acme/lib 3.5']]], ], - $data->buildDecisions(fn(string $map) => $map), + array_map( + fn(array $layer) => [$layer[0]->since, $layer[0]->from, $layer[1]], + $data->buildDecisions(fn(string $map) => $map), + ), ); }); @@ -319,8 +322,8 @@ test('a package the project requires itself is measured by the lowest version it $runner->processCode("$root/f.php", "output, ); Assert::same([ - 'The configuration names package `acme/ghost` in `targets`, but it is not installed; skipped.' => null, - 'The configuration names package `app/project` in `targets`, but that is the project itself, whose code is any version of it; skipped.' => null, + 'Package `acme/ghost` is named in `targets`, but it is not installed; skipped.' => null, + 'Package `app/project` is named in `targets`, but that is the project itself, whose code is any version of it; skipped.' => null, ], $resolution->warnings); }); @@ -591,7 +594,7 @@ test('what a package says lies under the maps of the project where it says upgra ); $layers = $resolution->resolvedConfig->decisions['upgrading.api.replacedClasses']->layers; Assert::same( - ['upgrading/upgrading.neon of acme/lib', 'the configuration'], + ['upgrading/upgrading.neon of acme/lib, since 3.0', 'the configuration'], array_map(fn($layer) => $layer->origin?->describe(), $layers), ); @@ -640,23 +643,77 @@ test('dresscode config lists the upgrading data with the sections the lowest ver }); -test('what a package declares in its namespaces lies under the lists of the project', function () { +test( + 'what a package declares in its namespaces lies under the lists of the project, and DressCode knows that of the configurator of Symfony', + function () { + $root = project( + 'namespaces', + [ + 'acme/lib' => ['3.2.0.0', ['upgrading-dir' => 'upgrading']], + 'symfony/dependency-injection' => ['7.3.0.0', []], + ], + [ + 'vendor/acme/lib/upgrading/upgrading.neon' => <<<'XX' + package: acme/lib + namespaces: + functions: ['Acme\Lib\{helper}'] + + XX, + ], + ); + $factory = new RunnerFactory; + $functions = $factory->resolve(new Config(namespaces: ['functions' => ['App\format']]), $root)->resolvedConfig->namespacedFunctions; + Assert::same('the configuration', $functions['App\format']); + Assert::same('upgrading/upgrading.neon of acme/lib', $functions['Acme\Lib\helper']); + Assert::same( + 'DressCode for symfony/dependency-injection', + $functions['Symfony\Component\DependencyInjection\Loader\Configurator\service'], + ); + }, +); + + +test('the upgrading data installed beside DressCode serves a project without it, and what its libraries miss is named', function () { $root = project( - 'namespaces', - [ - 'acme/lib' => ['3.2.0.0', ['upgrading-dir' => 'upgrading']], - ], - [ - 'vendor/acme/lib/upgrading/upgrading.neon' => <<<'XX' - package: acme/lib - namespaces: - functions: ['Acme\Lib\{helper}'] + 'data-beside', + ['nette/utils' => ['4.0.0.0', []], 'dibi/dibi' => ['5.0.0.0', []], 'symfony/console' => ['7.0.0.0', []]], + [], + ['require' => ['nette/utils' => '^4.0', 'dibi/dibi' => '^5.0']], + ); + $data = createTempDir('data-package'); + FileSystem::write( + "$data/composer.json", + json_encode(['name' => 'dresscode/upgrade-nette', 'extra' => ['dresscode' => ['upgrading-dir' => 'upgrading']]]), + ); + FileSystem::write( + "$data/upgrading/utils.neon", + "package: nette/utils\n\nsince 4.0:\n\treplacedClasses:\n\t\tNette\\Old: Nette\\Renamed\n", + ); + $project = ProjectPackages::read($root); - XX, - ], + // a library the project requires itself, as the package says it is about, whereas symfony/console comes with another + $catalogue = [ + 'dresscode/upgrade-nette' => ['nette/', 'latte/latte'], + 'dresscode/upgrade-deegee' => ['dibi/dibi'], + 'dresscode/upgrade-symfony' => ['symfony/console'], + ]; + Assert::same( + ['dresscode/upgrade-nette', 'dresscode/upgrade-deegee'], + PackageDiscovery::findMissingDataPackages($project, $catalogue, []), ); - $factory = new RunnerFactory; - $functions = $factory->resolve(new Config(namespaces: ['functions' => ['App\format']]), $root)->resolvedConfig->namespacedFunctions; - Assert::same('the configuration', $functions['App\format']); - Assert::same('upgrading/upgrading.neon of acme/lib', $functions['Acme\Lib\helper']); + Assert::same( + ['dresscode/upgrade-deegee'], + PackageDiscovery::findMissingDataPackages($project, $catalogue, ['dresscode/upgrade-nette' => $data]), + ); + Assert::same( + ['upgrading/utils.neon of dresscode/upgrade-nette'], + array_map( + fn($data) => $data->layer->describe(), + PackageDiscovery::discover($project, ['dresscode/upgrade-nette' => $data])->upgradingData, + ), + ); + + // a project with none of their libraries misses nothing + $other = ProjectPackages::read(project('data-none', ['acme/lib' => ['1.0.0.0', []]], [], ['require' => ['acme/lib' => '^1.0']])); + Assert::same([], PackageDiscovery::findMissingDataPackages($other, $catalogue, [])); }); diff --git a/tests/DressCode/Config/PluginRegistry.phpt b/tests/DressCode/Config/PluginRegistry.phpt index 1ba071cb90..7affba4a5f 100644 --- a/tests/DressCode/Config/PluginRegistry.phpt +++ b/tests/DressCode/Config/PluginRegistry.phpt @@ -106,6 +106,23 @@ test('names of a suppression comment: a decision or a section for itself, a rule Assert::same(['imports.order.names'], $registry->expandSuppressedName('imports.order.names')); Assert::same(['imports'], $registry->expandSuppressedName('imports')); Assert::same([], $registry->expandSuppressedName('importOrder')); + Assert::same(['imports.order.names', 'imports.order.kinds'], $registry->expandSuppressedName('ordered_imports')); + Assert::same( + ['imports.order.names', 'imports.order.kinds'], + $registry->expandSuppressedName('SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses'), + ); + // a message of a sniff stands for its own row of the table, and for nothing without one + Assert::same( + ['naming.property'], + $registry->expandSuppressedName('Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps'), + ); + Assert::same(['naming.variable'], $registry->expandSuppressedName('Squiz.NamingConventions.ValidVariableName.NotCamelCaps')); + Assert::same( + ['spacing.unaryOperator', 'spacing.logicalNot'], + $registry->expandSuppressedName('Squiz.WhiteSpace.OperatorSpacing.Unary'), + ); + Assert::same([], $registry->expandSuppressedName('PSR2.Methods.MethodDeclaration.Underscore')); + Assert::same([], $registry->expandSuppressedName('Squiz.NamingConventions.ValidVariableName.Unknown')); Assert::same([], $registry->expandSuppressedName('test/unknown')); Assert::same([], $registry->expandSuppressedName('imports.ord')); }); @@ -136,6 +153,10 @@ test('errors', function () { ConfigurationException::class, 'Unknown preset `dresscode/nete`. Did you mean `dresscode/nette`?', ); + Assert::same( + 'Unknown decision, preset or rule `cast_spaces`. It is covered by `spacing.cast`; `dresscode migrate config` translates a configuration of another tool.', + $registry->createUnknownNameException('cast_spaces')->getMessage(), + ); Assert::exception( fn() => RuleInfo::fromRule(NoInfo::class), ConfigurationException::class, @@ -192,8 +213,9 @@ test('presets', function () { Assert::same('dresscode/perCs', $registry->resolvePreset('perCs')); Assert::same('test/preset', $registry->resolvePreset('test/preset')); Assert::same([ - 'dresscode/perCs', 'dresscode/psr12', 'dresscode/nette', 'dresscode/cleanup', 'dresscode/compilerOptimizations', - 'dresscode/correctness', 'dresscode/deprecations', 'dresscode/modernizations', 'dresscode/types', 'test/preset', + 'dresscode/perCs', 'dresscode/psr12', 'dresscode/nette', 'dresscode/symfony', 'dresscode/laravel', 'dresscode/cleanup', + 'dresscode/compilerOptimizations', 'dresscode/correctness', 'dresscode/deprecations', 'dresscode/modernizations', 'dresscode/types', + 'test/preset', ], array_keys($registry->presets)); Assert::match('%a%/src/Presets/perCs.neon', $registry->presets['dresscode/perCs']); Assert::exception(fn() => $registry->resolvePreset('none'), ConfigurationException::class, 'Unknown preset `none`.'); diff --git a/tests/DressCode/Config/RunnerFactory.phpt b/tests/DressCode/Config/RunnerFactory.phpt index cf6f428529..7acb6074d1 100644 --- a/tests/DressCode/Config/RunnerFactory.phpt +++ b/tests/DressCode/Config/RunnerFactory.phpt @@ -4,9 +4,9 @@ use Composer\InstalledVersions; use DressCode\{ CodeWriter, Config, ConfigurationException, Decision, Domain, NodeRule, Override, Plugin, PluginManifest, Profile, RuleContext, RuleInfo, Stage, }; -use DressCode\Config\{ComposerJson, PhpVersionSource, RunnerFactory}; -use DressCode\Engine\{Runner, TypeAnalysisStatus}; -use DressCode\Reporters\NullReporter; +use DressCode\Config\{ComposerJson, PhpVersionSource, RunnerFactory, UpgradingData}; +use DressCode\Engine\{Baseline, Runner, TypeAnalysisStatus}; +use DressCode\Reporters\{CollectingReporter, NullReporter}; use DressCode\Rules\ControlFlow\MatchForSwitchRule; use PhpSyntax\{Node, Token}; use PhpSyntax\Nodes\Expression\VariableNode; @@ -324,10 +324,11 @@ test('a rule of a plugin is built by the factory of its manifest, anew for every overrides: [new Override(['tests'], new Profile(targets: ['php' => '8.4']))], decisions: ['dependent' => ['variables' => 'forbidden']], ); - $result = buildRunner($config, $root)->run(['a.php', 'tests/b.php'], false, new NullReporter); + $collected = new CollectingReporter; + buildRunner($config, $root)->run(['a.php', 'tests/b.php'], false, $collected); Assert::same( ['A variable, served.', 'A variable, served.'], - array_map(fn($file) => $file->violations[0]->message, $result->files), + array_map(fn(array $violations) => $violations[0]->message, array_values($collected->violations)), ); Assert::count(2, $plugin->built); Assert::notSame($plugin->built[0], $plugin->built[1]); @@ -485,10 +486,11 @@ test('a configuration of PHPStan it cannot start with is a warning of the run, a decisions: ['upgrading' => ['declarations' => ['deprecatedMember' => 'forbidden']]], ); $runner = $factory->createRunner($factory->resolve($config, $root), cache: false); - $result = $runner->run(['Check.php'], false, new NullReporter); + $collected = new CollectingReporter; + $result = $runner->run(['Check.php'], false, $collected); Assert::same(['5: upgrading.declarations.deprecatedMember'], array_map( fn($violation) => "$violation->line: $violation->decision", - $result->files[0]->violations, + $collected->violations['Check.php'], )); Assert::match('PHPStan could not start with `phpstan.neon` of the project: %a%; the types go without it.', $result->warnings[0] ?? ''); }); @@ -565,6 +567,8 @@ test('what the data of PHP say is reported under the requirement that lays them, Assert::same(['2: upgrading.php.deprecatedApi', '3: upgrading.api.forbiddenFunctions'], $decisions(null)); Assert::same(['2: upgrading.php.deprecatedApi'], $decisions('upgrading.php.deprecatedApi')); + // the package of the data stands for that requirement too, beside the maps it writes into, which the project's entries share + Assert::same(['2: upgrading.php.deprecatedApi', '3: upgrading.api.forbiddenFunctions'], $decisions('php')); }); @@ -601,6 +605,59 @@ test('every rule the data of PHP feed reports what they say under the requiremen }); +test('a violation of an entry of upgrading data names its section and the version its fix is right from', function () { + $root = createTempDir('data-source'); + $factory = new RunnerFactory(phpstanInstalled: false); + $upgrading = ['php' => ['deprecatedApi' => 'forbidden'], 'api' => ['forbiddenFunctions' => ['acme_retired' => 'gone']]]; + $config = new Config(targets: ['php' => '8.0'], decisions: ['upgrading' => $upgrading]); + $code = "createRunner($factory->resolve($config, $root), cache: false) + ->processCode("$root/Check.php", $code) + ->violations; + + // an entry the project wrote has none + Assert::same( + [['php.neon', 'php', '8.5', null], ['php.neon', 'php', '8.5', '8.0'], null], + array_map( + fn($violation) => $violation->source === null + ? null + : [$violation->source->name, $violation->source->library, $violation->source->since, $violation->source->from], + $violations, + ), + ); +}); + + +test('the targets of the command line cut the upgrading data before they are laid, over those of the configuration', function () { + $root = createTempDir('targets-command'); + file_put_contents("$root/composer.json", '{"require": {"php": "^8.0"}}'); + $code = <<<'XX' + setAccessible(true); + } + XX; + file_put_contents("$root/Check.php", $code); + $factory = new RunnerFactory; + $config = new Config(typeAnalysis: 'phpstan', decisions: ['upgrading' => ['php' => ['deprecatedApi' => 'forbidden']]]); + $run = function (?Config $commandLine) use ($factory, $config, $root, $code): array { + $resolution = $factory->resolve($config, $root, $commandLine); + $php = array_find(array_column($resolution->upgradingData, 0), fn(UpgradingData $data) => $data->package === 'php'); + $result = $factory->createRunner($resolution, cache: false)->processCode("$root/Check.php", $code); + return [$resolution->resolvedConfig->phpVersion, $php?->unreached[0], $result->violations[0]->fixable]; + }; + + // what PHP 8.1 made a no-op is reported for code that runs on 8.0, and removed once the command line moves it to 8.1 + Assert::same(['8.0', '8.1', false], $run(null)); + Assert::same(['8.1', '8.3', true], $run(new Config(targets: ['php' => '8.1']))); + + $resolution = $factory->resolve(new Config(targets: ['acme/mail' => '2.0']), $root, new Config(targets: ['acme/mail' => '3.0'])); + Assert::same(['acme/mail' => '3.0'], $resolution->resolvedConfig->packageTargets); +}); + + test('the command line asks for the types without a configuration that sets them, and PHPStan missing refuses the run', function () { $root = createTempDir('types-command'); $commandLine = new Config(use: ['deprecations'], typeAnalysis: 'phpstan'); @@ -691,7 +748,7 @@ test('the engine is built from the configuration', function () use ($fixtures) { ), "$fixtures/project", ); - Assert::same([], $runner->findFiles(['src'])); + Assert::same([], $runner->files->findFiles(['src'])); $result = $runner->processCode('x.php', " $v->message, $result->violations)); @@ -747,7 +804,7 @@ test( Assert::same([PluginRule::class], array_map(fn($rule) => $rule->class, $resolution->resolvedConfig->getActiveRules())); // the paths of every layer add up, and a file any layer skips is skipped - $files = $runner->findFiles(['.']); + $files = $runner->files->findFiles(['.']); Assert::same(['checked.php', 'generated.php', 'skipped.php'], $files); Assert::same(['checked.php'], array_map(fn($result) => $result->path, $runner->run($files, false, new NullReporter)->files)); }, @@ -1123,11 +1180,15 @@ test('a package the project upgrades is part of the identity, however the tool i }); -test('the name of the baseline is judged even before the file exists', function () use ($fixtures) { - Assert::null(RunnerFactory::loadBaseline(new Config, $fixtures)); - Assert::null(RunnerFactory::loadBaseline(new Config(baseline: 'baseline.neon'), $fixtures)); // no file yet +test('a baseline the configuration names must exist, and its name is judged first', function () use ($fixtures) { + Assert::null(Baseline::open(new Config, $fixtures)); + Assert::exception( + fn() => Baseline::open(new Config(baseline: 'baseline.neon'), $fixtures), + ConfigurationException::class, + 'Baseline file `%a%baseline.neon` does not exist; `dresscode baseline` writes it.', + ); Assert::exception( - fn() => RunnerFactory::loadBaseline(new Config(baseline: 'baseline.txt'), $fixtures), + fn() => Baseline::open(new Config(baseline: 'baseline.txt'), $fixtures), ConfigurationException::class, 'Baseline file `%a%baseline.txt` must be a `.neon` or a `.php` file.', ); diff --git a/tests/DressCode/Config/UpgradingFile.phpt b/tests/DressCode/Config/UpgradingFile.phpt index a5705c0b2a..ac5543ae9c 100644 --- a/tests/DressCode/Config/UpgradingFile.phpt +++ b/tests/DressCode/Config/UpgradingFile.phpt @@ -63,11 +63,15 @@ test('the sentence names the package and the version, PHP as PHP', function () { XX); $data = $upgrading->selectFor('>=8.0', new Layer(LayerKind::Package, 'php.neon', 'php')); + // every entry in the layer of its section, with the version its fix is right from Assert::same( [ - 'replacedCalls' => ['curl_close($handle)' => 'removed'], - 'forbiddenFunctions' => ['socket_set_timeout' => 'since PHP 8.5'], + ['php', '8.5', '8.0', ['replacedCalls' => ['curl_close($handle)' => 'removed']]], + ['php', '8.5', null, ['forbiddenFunctions' => ['socket_set_timeout' => 'since PHP 8.5']]], ], - $data->buildDecisions(fn(string $map) => $map), + array_map( + fn(array $layer) => [$layer[0]->library, $layer[0]->since, $layer[0]->from, $layer[1]], + $data->buildDecisions(fn(string $map) => $map), + ), ); }); diff --git a/tests/DressCode/Console/Application.helpers.php b/tests/DressCode/Console/Application.helpers.php new file mode 100644 index 0000000000..eb99f63b2b --- /dev/null +++ b/tests/DressCode/Console/Application.helpers.php @@ -0,0 +1,172 @@ +name instanceof Token && $node->name->text === '$a') { + if ($context->report($node, 'Rename $a.')) { + $node->name->setText('$b'); + } + } + } +} + + +#[RuleInfo(Stage::Structure)] +final class ConsoleRiskyRename extends NodeRule +{ + public static function getDecisions(): array + { + return [new Decision('project.riskyRename', Domain::state('forbidden'), '`$r` is `$s`')]; + } + + + public function getVisitedNodes(): array + { + return [VariableNode::class]; + } + + + public function enter(Node|Token $node, RuleContext $context): void + { + if ($node instanceof VariableNode && $node->name instanceof Token && $node->name->text === '$r') { + if ($context->report($node, 'Rename $r.', risk: Risk::BehaviorChanges)) { + $node->name->setText('$s'); + } + } + } +} + + +#[RuleInfo(Stage::Formatting)] +final class ConsoleReport extends NodeRule +{ + public static function getDecisions(): array + { + return [new Decision('project.report', Domain::state('forbidden'), 'A variable is reported')]; + } + + + public function getVisitedNodes(): array + { + return [VariableNode::class]; + } + + + public function enter(Node|Token $node, RuleContext $context): void + { + $context->report($node, 'Seen.', fixable: false); + } +} + + +#[RuleInfo(Stage::Formatting)] +final class ConsoleFailing extends NodeRule +{ + public static function getDecisions(): array + { + return [new Decision('project.failing', Domain::state('forbidden'), 'A variable breaks the rule')]; + } + + + public function getVisitedNodes(): array + { + return [VariableNode::class]; + } + + + public function enter(Node|Token $node, RuleContext $context): void + { + throw new LogicException('Broken rule'); + } +} + + +/** the rules of the project every configuration of these tests names */ +const ConsoleRules = [ConsoleRename::class, ConsoleRiskyRename::class, ConsoleReport::class, ConsoleFailing::class]; + + +final class ConsolePlugin implements Plugin +{ + public function getManifest(): PluginManifest + { + return new PluginManifest; + } +} + + +/** A project of its own with the configuration most tests run with, src/a.php to rename and src/b.php clean. */ +function createConsoleProject(): string +{ + $root = createTempDir('console'); + mkdir("$root/src"); + // the target is pinned, so that a rule of a newer PHP has something to be newer than + file_put_contents( + "$root/dresscode.php", + " ['rename' => 'forbidden']], paths: ['src'], targets: ['php' => '8.3']);\n", + ); + file_put_contents("$root/src/a.php", " $args + * @return array{int, string, string} + * @phpstan-impure + */ +function runApp(string $root, array $args, string $stdin = '', bool $xdebug = false, bool $interactive = false): array +{ + $out = fopen('php://memory', 'w+') ?: throw new RuntimeException; + $err = fopen('php://memory', 'w+') ?: throw new RuntimeException; + $in = fopen('php://memory', 'w+') ?: throw new RuntimeException; + fwrite($in, $stdin); + rewind($in); + $runs = in_array($args[0] ?? null, ['check', 'fix', 'baseline'], strict: true) || array_slice($args, 0, 2) === ['upgrade', 'today']; + if ($runs && !in_array('--jobs', $args, strict: true)) { + $args = [...$args, '--jobs', '1']; // the rules of this file do not exist in a worker process + } + + $code = new Application( + $out, + $err, + $in, + $root, + script: __DIR__ . '/../../../bin/dresscode', + xdebug: $xdebug, + interactive: $interactive, + )->run( + [ + 'dresscode', + ...$args, + ], + ); + rewind($out); + rewind($err); + // how long a run took is up to the machine, which a busy one makes long enough to be said + $clean = fn($stream) => (string) preg_replace('~, \d+\.\d s$~m', '', (string) stream_get_contents($stream)); + return [$code, $clean($out), $clean($err)]; +} diff --git a/tests/DressCode/Console/Application.migrate.phpt b/tests/DressCode/Console/Application.migrate.phpt new file mode 100644 index 0000000000..48d40fe55e --- /dev/null +++ b/tests/DressCode/Console/Application.migrate.phpt @@ -0,0 +1,341 @@ + + + + + + + + + + + + + + + XX); + $dir = createTempDir('import'); + // a tool reads the translation as data, a dry run writing nothing + [$code, $out] = runApp( + $root, + ['migrate', 'config', "$root/phpcs.xml", '--output', "$root/migrated.php", '--dry-run', '--format', 'json'], + ); + Assert::same(1, $code); + $data = json_decode($out, associative: true, flags: JSON_THROW_ON_ERROR); + Assert::same( + [DressCode\Console\Commands\MigrateCommand::Version, null, ['dresscode/psr12']], + [$data['version'], $data['written'], $data['presets']], + ); + Assert::match(' [\n" + . "\t\t\t'bodyStart' => [\n" + . "\t\t\t\t'class' => 0,\n" + . "\t\t\t],\n" + . "\t\t\t'bodyEnd' => [\n" + . "\t\t\t\t'class' => 0,\n" + . "\t\t\t],\n" + . "\t\t\t'aroundMethods' => 1,\n" + . "\t\t\t'aroundInterfaceMethods' => 1,\n" + . "\t\t\t'aroundDeclarations' => 1,\n" + . "\t\t],\n" + . "\t\t'multiline' => [\n" + . "\t\t\t'trailingComma' => [\n" + . "\t\t\t\t'array' => 'required',\n" + . "\t\t\t\t'argument' => 'required',\n" + . "\t\t\t\t'parameter' => 'keep',\n" + . "\t\t\t\t'matchArm' => 'keep',\n" + . "\t\t\t\t'closureUse' => 'keep',\n" + . "\t\t\t\t'import' => 'keep',\n" + . "\t\t\t\t'list' => 'keep',\n" + . "\t\t\t],\n" + . "\t\t],\n" + . "\t],\n" + . ");\n", + file_get_contents("$root/migrated.php"), + ); + Assert::match( + "Written `%a%`.\n\nRead 5 rules; set 12 decisions and 1 preset, the rules translated; `dresscode init` measures the code instead.\n" + . " `Squiz.WhiteSpace.FunctionSpacing` leaves the blank lines around classes alone, while `blankLines` sets them together with those around functions.\n" + . " No DressCode rule covers `Squiz.Nonsense.DoesNotExist`.\n", + $err, + ); + + file_put_contents( + "$root/fixer.php", + " ['space' => 'none']];\n\t}\n};\n", + ); + [$code] = runApp($root, ['migrate', 'config', 'fixer.php', '-o', 'dresscode-fixer.php']); // relative to the working directory of the run + Assert::same(0, $code); + Assert::contains("\t\t'spacing' => [\n\t\t\t'cast' => '(int)\$x',\n\t\t],\n", (string) file_get_contents("$root/dresscode-fixer.php")); + + // what narrows the finder in a way no scope writes is said, and the translation is not whole + file_put_contents( + "$root/fixer-filtered.php", + "filter(fn() => true); }\nreturn ['cast_spaces' => true];\n", + ); + [$code, , $err] = runApp($root, ['migrate', 'config', 'fixer-filtered.php', '-o', 'dresscode-filtered.php']); + Assert::same(1, $code); + Assert::contains( + '`filter()` of `fixer-filtered.php` is not carried over; what the configuration checks may differ from what PHP CS Fixer checked.', + $err, + ); + + // the file is written in the format of its extension, as a configuration the run reads + [$code, $out] = runApp($dir, ['migrate', 'config', "$root/phpcs.xml"]); + Assert::same([1, ''], [$code, $out]); + $neon = (string) file_get_contents("$dir/dresscode.neon"); + Assert::match("use: psr12\n\npaths: [.]\n\nblankLines:\n%A%\n\nmultiline:\n\ttrailingComma:\n\t\tarray: required\n%A%", $neon); + // what a foreign rule left untranslated stays in the file at a decision it gave + Assert::contains('aroundDeclarations: 1 # `Squiz.WhiteSpace.FunctionSpacing` leaves the blank lines around classes alone', $neon); + Assert::same( + 'required', + DressCode\Config\NeonReader::read("$dir/dresscode.neon")->decisions['multiline']['trailingComma']['array'] ?? null, + ); + + [$code, , $err] = runApp($dir, ['migrate', 'config', "$root/phpcs.xml"]); + Assert::same(3, $code); + Assert::contains('exists already', $err); + // a configuration of the other format collides too, which a run would refuse beside it + [$code, , $err] = runApp($dir, ['migrate', 'config', "$root/phpcs.xml", '-o', 'dresscode.php']); + Assert::same(3, $code); + Assert::contains('The configuration `dresscode.neon` exists already', $err); + Assert::false(is_file("$dir/dresscode.php")); + [$code, , $err] = runApp($root, ['migrate', 'config', "$root/phpcs.xml", '-o', "$dir/dresscode.yaml"]); + Assert::same(3, $code); + Assert::contains('its extension is `.neon` or `.php`', $err); + // the paths it names are relative to the project, which a configuration elsewhere would read relative to itself + [$code, , $err] = runApp($root, ['migrate', 'config', "$root/phpcs.xml", '-o', "$dir/other.neon"]); + Assert::same(3, $code); + Assert::contains('would lie outside the root of the project', $err); +}); + + +test('migrate config without a file finds the configuration the project has, and runs one of PHP CS Fixer only when agreed', function () { + $project = createTempDir('import-find'); + [$code, , $err] = runApp($project, ['migrate', 'config']); + Assert::same(3, $code); + Assert::contains('The project has no configuration of PHP CS Fixer, PHP_CodeSniffer or Laravel Pint', $err); + + // the preset of Laravel Pint is the standard, its rules those of PHP CS Fixer + file_put_contents("$project/pint.json", '{"preset": "per", "rules": {"cast_spaces": {"space": "none"}}}'); + [$code, , $err] = runApp($project, ['migrate', 'config']); + Assert::same(0, $code); + Assert::contains('Found `pint.json` of Laravel Pint.', $err); + Assert::match("use: perCs\n%A%cast: '(int)\$x'%A%", (string) file_get_contents("$project/dresscode.neon")); + + // a dry run prints what it would write, over a file that exists too, and writes nothing + $written = (string) file_get_contents("$project/dresscode.neon"); + file_put_contents("$project/dresscode.neon", ''); + [$code, $out, $err] = runApp($project, ['migrate', 'config', '--dry-run']); + Assert::same([0, $written], [$code, $out]); + Assert::contains('Nothing written, as `--dry-run` asks.', $err); + Assert::same('', file_get_contents("$project/dresscode.neon")); + + $project = createTempDir('import-fixer'); + file_put_contents( + "$project/.php-cs-fixer.dist.php", + " true];\n", + ); + file_put_contents("$project/composer.json", '{"scripts": {"cs": "php-cs-fixer fix --config=.php-cs-fixer.dist.php"}}'); + [$code, , $err] = runApp($project, ['migrate', 'config']); + Assert::same(3, $code); + Assert::contains( + '`.php-cs-fixer.dist.php` is PHP, which reading runs, so it is read only when named: `dresscode migrate config .php-cs-fixer.dist.php`.', + $err, + ); + Assert::false(is_file("$project/ran")); + + [$code, $out, $err] = runApp($project, ['migrate', 'config'], "\n", interactive: true); + Assert::same(0, $code); + Assert::contains('Found `.php-cs-fixer.dist.php` of PHP CS Fixer, run by `composer cs`.', $err); + Assert::contains('Read it? [y/N]', $out); + Assert::contains('Nothing written.', $err); + Assert::false(is_file("$project/ran")); + Assert::false(is_file("$project/dresscode.neon")); + + [$code] = runApp($project, ['migrate', 'config'], "y\n", interactive: true); + Assert::same(0, $code); + Assert::true(is_file("$project/ran")); + Assert::true(is_file("$project/dresscode.neon")); +}); + + +test('migrate config asks which configuration a project with several runs, and elsewhere takes the first and says so', function () { + $project = createTempDir('import-several'); + file_put_contents("$project/phpcs.xml", ''); + file_put_contents("$project/pint.json", '{"preset": "per"}'); + [$code, , $err] = runApp($project, ['migrate', 'config']); + Assert::same(0, $code); + Assert::contains( + 'The project has also `pint.json` of Laravel Pint; the first is taken, and `dresscode migrate config ` translates another.', + $err, + ); + + unlink("$project/dresscode.neon"); + [$code, $out, $err] = runApp($project, ['migrate', 'config'], "2\n", interactive: true); + Assert::same(0, $code); + Assert::contains(" [1] `phpcs.xml` of PHP_CodeSniffer\n [2] `pint.json` of Laravel Pint\nWhich one does it run? [1/2]", $out); + Assert::contains('Found `pint.json` of Laravel Pint.', $err); +}); + + +test('migrate config writes the paths the configuration names, and keeps the decisions of a sniff where it leaves files out', function () { + $project = createTempDir('import-scope'); + foreach (['src/A.php', 'src/drivers/B.php', 'tests/C.php'] as $file) { + @mkdir(dirname("$project/$file"), recursive: true); // @ directory may already exist + file_put_contents("$project/$file", " + + src + + + */drivers/* + + + XX); + [$code] = runApp($project, ['migrate', 'config']); + Assert::same(0, $code); + $neon = (string) file_get_contents("$project/dresscode.neon"); + Assert::contains("use: psr12\n\npaths: [src]\n\n", $neon); + Assert::match( + "%A%overrides:\n\t# where PSR1.Methods.CamelCapsMethodName is left out by your formatter\n\t-\n%A%src/drivers%A%method: keep\n", + $neon, + ); + + // the data of tests the ruleset checked are left out as init leaves them out, which the run says is a change + unlink("$project/dresscode.neon"); + @mkdir("$project/src/fixtures"); // @ directory may already exist + file_put_contents("$project/src/fixtures/D.php", "\n\n\tsrc\n\t\n\n", + ); + [$code, , $err] = runApp($project, ['migrate', 'config']); + Assert::same(0, $code); + Assert::contains("\n2 phpcs suppression comments: `dresscode migrate suppressions` rewrites them.\n", $err); +}); + + +test('migrate suppressions rewrites phpcs comments to the dresscode form', function () { + $root = createConsoleProject(); + file_put_contents( + "$root/src/s.php", + " ['src/s.php:8']], $data['unknown']); + Assert::same($original, file_get_contents("$root/src/s.php")); + + // a name nothing covers is a migration not done, an ignore whose scope grows one to review, each where it stands + [$code, $out, $err] = runApp($root, ['migrate', 'suppressions', 'src/s.php']); + Assert::same('', $err); + Assert::same(2, $code); + Assert::same( + "Migrated 5 suppressions in 1 file.\n" + . "Unknown `Generic.Metrics.CyclomaticComplexity`, kept as written, as no decision answers to it: src/s.php:8\n" + . "Widened src/s.php:8: a `dresscode:ignore` on a line of its own covers the whole statement below it, not just the next line\n", + $out, + ); + Assert::same( + " new DressCode\Engine\SuppressionMigration(new DressCode\Config\PluginRegistry()->expandSuppressedName(...)) + ->countInFiles($root, ['src/s.php']); + Assert::same(0, $count()); + + // an ignore before code on its line keeps covering that line, so there is nothing to review + file_put_contents("$root/src/t.php", "name instanceof Token && $node->name->text === '$a') { - if ($context->report($node, 'Rename $a.')) { - $node->name->setText('$b'); - } - } - } -} - - -#[RuleInfo(Stage::Structure)] -final class ConsoleRiskyRename extends NodeRule -{ - public static function getDecisions(): array - { - return [new Decision('project.riskyRename', Domain::state('forbidden'), '`$r` is `$s`')]; - } - - - public function getVisitedNodes(): array - { - return [VariableNode::class]; - } - - - public function enter(Node|Token $node, RuleContext $context): void - { - if ($node instanceof VariableNode && $node->name instanceof Token && $node->name->text === '$r') { - if ($context->report($node, 'Rename $r.', risk: Risk::BehaviorChanges)) { - $node->name->setText('$s'); - } - } - } -} - - -#[RuleInfo(Stage::Formatting)] -final class ConsoleReport extends NodeRule -{ - public static function getDecisions(): array - { - return [new Decision('project.report', Domain::state('forbidden'), 'A variable is reported')]; - } - - - public function getVisitedNodes(): array - { - return [VariableNode::class]; - } - - - public function enter(Node|Token $node, RuleContext $context): void - { - $context->report($node, 'Seen.', fixable: false); - } -} - - -#[RuleInfo(Stage::Formatting)] -final class ConsoleFailing extends NodeRule -{ - public static function getDecisions(): array - { - return [new Decision('project.failing', Domain::state('forbidden'), 'A variable breaks the rule')]; - } - - - public function getVisitedNodes(): array - { - return [VariableNode::class]; - } - - - public function enter(Node|Token $node, RuleContext $context): void - { - throw new LogicException('Broken rule'); - } -} - - -/** the rules of the project every configuration of this file names */ -const ConsoleRules = [ConsoleRename::class, ConsoleRiskyRename::class, ConsoleReport::class, ConsoleFailing::class]; - - -final class ConsolePlugin implements Plugin -{ - public function getManifest(): PluginManifest - { - return new PluginManifest; - } -} - - -/** A project of its own with the configuration most tests run with, src/a.php to rename and src/b.php clean. */ -function createConsoleProject(): string -{ - $root = createTempDir('console'); - mkdir("$root/src"); - // the target is pinned, so that a rule of a newer PHP has something to be newer than - file_put_contents( - "$root/dresscode.php", - " ['rename' => 'forbidden']], paths: ['src'], targets: ['php' => '8.3']);\n", - ); - file_put_contents("$root/src/a.php", " $args - * @return array{int, string, string} - */ -function runApp(string $root, array $args, string $stdin = '', bool $xdebug = false, bool $interactive = false): array -{ - $out = fopen('php://memory', 'w+') ?: throw new RuntimeException; - $err = fopen('php://memory', 'w+') ?: throw new RuntimeException; - $in = fopen('php://memory', 'w+') ?: throw new RuntimeException; - fwrite($in, $stdin); - rewind($in); - if (in_array($args[0] ?? null, ['check', 'fix', 'baseline'], strict: true) && !in_array('--jobs', $args, strict: true)) { - $args = [...$args, '--jobs', '1']; // the rules of this file do not exist in a worker process - } - - $code = new Application( - $out, - $err, - $in, - $root, - script: __DIR__ . '/../../../bin/dresscode', - xdebug: $xdebug, - interactive: $interactive, - )->run( - [ - 'dresscode', - ...$args, - ], - ); - rewind($out); - rewind($err); - // how long a run took is up to the machine, which a busy one makes long enough to be said - $clean = fn($stream) => (string) preg_replace('~, \d+\.\d s$~m', '', (string) stream_get_contents($stream)); - return [$code, $clean($out), $clean($err)]; -} - - test('help and version', function () use ($root) { [$code, $out, $err] = runApp($root, []); Assert::same(3, $code); @@ -475,6 +311,7 @@ test('catalogue lists every decision, those the configuration makes marked, and $data = json_decode($out, associative: true); Assert::same(DressCode\Config\Catalogue::Version, $data['version']); Assert::same('foo($a, $b)', $data['decisions']['spacing.call']['standards']['nette']); + Assert::contains('no_spaces_after_function_name', $data['decisions']['spacing.call']['covers']); }); @@ -568,7 +405,7 @@ test('without a configuration file neither check nor fix runs, unless a preset i [$code, , $err] = runApp($dir, [$command]); Assert::same(3, $code); Assert::match( - "Error: No `dresscode.neon` or `dresscode.php` found in `%a%` or above it, so there is no dress code to check against. Name a standard with `--use`.\n", + "Error: No `dresscode.neon` or `dresscode.php` found in `%a%` or above it, so there is no dress code to check against. Run `dresscode init`%A%.\nSee https://dresscode.run/cli#init\n", $err, ); } @@ -628,8 +465,9 @@ test('a baseline is generated by the baseline command over what a fix leaves and Assert::match("Error: %a%`$option`%a%\n%A%", $err); } - // a baseline that cannot be read is replaced + // a baseline that cannot be read is replaced, and it is no obstacle to the migration of the comments file_put_contents("$root/baseline.neon", "a: [b\n"); + Assert::same(0, runApp($root, ['migrate', 'suppressions', '--config', "$root/baseline.php"])[0]); [$code, $out, $err] = runApp($root, ['baseline', '--config', "$root/baseline.php", '--format', 'json']); Assert::same(0, $code); Assert::same('', $out); // a machine-readable format keeps its stream to itself @@ -664,169 +502,33 @@ test('a baseline is generated by the baseline command over what a fix leaves and Assert::match('%A%"warnings": []%A%', $out); file_put_contents("$root/src/a.php", " ['report' => 'forbidden']], paths: ['src']);\n", ); [$code, $out] = runApp($root, ['baseline', '--config', "$root/report.php"]); Assert::same(0, $code); - Assert::match( - "Baseline with 2 violations written to `dresscode-baseline.php`.\nName it under `baseline` in the configuration to make it apply.\n", - $out, - ); + Assert::same("Baseline with 2 violations written to `dresscode-baseline.php`.\n", $out); Assert::match("%A%'decision' => 'project.report',%A%", (string) file_get_contents("$root/dresscode-baseline.php")); -}); - - -test('workers give the same results as the in-process run', function () use ($root) { - file_put_contents( - "$root/jobs.php", - " ['trailingWhitespace' => 'forbidden']]);\n", - ); - @mkdir("$root/jobs"); // @ directory may already exist - file_put_contents("$root/jobs/a.php", " ['variables' => 'forbidden']]);\n", - ); - $config = ['--config', "$root/factory.php", '--no-cache']; - [, $expected] = runApp($root, ['check', ...$config, '--jobs', '1']); - [$code, $out, $err] = runApp($root, ['check', ...$config, '--jobs', '2']); - Assert::same('', $err); - Assert::same(1, $code); - Assert::same($expected, $out); - Assert::contains('A variable, built by the factory.', $out); -}); + Assert::match("%A%Baseline %a%dresscode-baseline.php\n%A%OK 2 violations in the baseline%A%", $out); + unlink("$root/dresscode-baseline.php"); - -test('a file a worker cannot write fails alone, the others are fixed', function () use ($root) { + // a baseline the configuration names must exist file_put_contents( - "$root/readonly.php", - " ['trailingWhitespace' => 'forbidden']]);\n", + "$root/missing.php", + " ['typo' => 'forbidden']], paths: ['src']); - PHP); - [$code, , $err] = runApp($root, ['check', '--config', "$root/typo.php", '--no-cache', '--jobs', '2']); + [$code, , $err] = runApp($root, ['check', '--config', "$root/missing.php"]); Assert::same(3, $code); - Assert::match('%A%Rule `ConsoleTypo` visits `Acme\Shop\NoSuchNode`, which is no class of a node or a token.%A%', $err); -}); - - -test('a worker has the settings of the process the ini file alone would not give it', function () use ($root) { - // the configuration turns the rule on by a setting, which a worker has only when it is handed over - file_put_contents( - "$root/ini.php", - " ['trailingWhitespace' => ini_get('user_agent') === 'dresscode-test' ? 'forbidden' : 'keep']]);\n", - ); - @mkdir("$root/ini"); // @ directory may already exist - file_put_contents("$root/ini/a.php", " ['strictArgument' => 'required']]);\n", + Assert::contains( + 'missing-baseline.neon` does not exist; `dresscode baseline` writes it.', + $err, ); - $config = ['--config', "$root/jobs.php", '--no-cache']; - - // the risky fixes the configuration does not accept are what a fix leaves - runApp($root, ['baseline', ...$config, '--jobs', '1']); - $single = (string) file_get_contents("$root/jobs-baseline.neon"); - runApp($root, ['baseline', ...$config, '--jobs', '3']); - Assert::same($single, (string) file_get_contents("$root/jobs-baseline.neon")); - Assert::match('%A%decision: mistakes.strictArgument%A%', $single); - - // what the baseline holds is not reported, and the fix the run does not allow is not made - [$code, $out] = runApp($root, ['fix', ...$config, '--jobs', '3']); - Assert::same(0, $code); - Assert::match("%A%OK 3 violations in the baseline in 3 files\n", $out); - Assert::same($a, (string) file_get_contents("$root/jobs/a.php")); - - // once the run allows it, the fix is made, with workers as without them - foreach (['1', '3'] as $jobs) { - file_put_contents("$root/jobs/a.php", $a); - file_put_contents("$root/jobs/c.php", $c); - [$code] = runApp($root, ['fix', ...$config, '--fix-risky', '--jobs', $jobs]); - Assert::same(0, $code); - Assert::same(" 'php', 'version' => '8.4']], $estimate['targets']); + Assert::same( + ['php jump ^8.4', 'php bridge ^8.0', 'upgrading.php.implicitNullable bridge ^8.0'], + array_map(fn(array $topic) => "$topic[name] $topic[phase] $topic[constraint]", $estimate['topics']), + ); + + // a run of the plan keeps the style of the configuration, which the project does not have + [$exit, , $err] = runApp($root, ['upgrade', 'run', 'php', '8.4', '--no-types', '--offline']); + Assert::same(3, $exit); + Assert::contains('and the project has none; write it with `dresscode init` first.', $err); + + // the paths named narrow the configured ones + file_put_contents("$root/src/Other.php", " 1, 'topic' => 'php', 'files' => []]], array_slice($run['outcome']['applied'], 0, 1)); + Assert::same('upgrading.php.deprecatedApi', $run['outcome']['tasks'][0]['decision']); + Assert::same('composer', $run['plan']['steps'][1]['kind']); + file_put_contents("$root/composer.json", '{"require": {"php": "^8.4"}}'); + [$exit, $out] = runApp($root, ['upgrade', 'run', '--no-types', '--no-color']); + Assert::same(1, $exit); + Assert::match(<<<'XX' + %A%Step 3 php left nothing to fix + Manual 1 task the fixes left + src/Code.php:5 Function `strftime()` is deprecated: since PHP 8.1. [upgrading.php.deprecatedApi] + Waiting step 3 leaves the manual tasks above: %a% + XX, $out); + + // a task confirmed with a reason is done + $code = str_replace( + "\tstrftime", + "\t// dresscode:ignore upgrading.php.deprecatedApi -- no replacement yet\n\tstrftime", + FileSystem::read("$root/src/Code.php"), + ); + file_put_contents("$root/src/Code.php", $code); + [$exit, $out] = runApp($root, ['upgrade', 'run', '--no-types', '--no-color']); + Assert::same(0, $exit); + // the last step updated the packages to the newest versions, which nothing has to be said of then + Assert::match( + "%A%Step 3 php left nothing to fix\nUPGRADED 4 steps done, and the final check finds nothing more to upgrade, no tests run", + $out, + ); +}); + + +test('upgrade run --paths narrows the plan, whose run changes nothing outside them', function () { + $root = createTempDir('plan-paths'); + mkdir("$root/src"); + file_put_contents("$root/composer.json", '{"type": "project", "require": {"php": "^8.0"}}'); + file_put_contents("$root/dresscode.neon", "paths: [src]\ncacheDir: cache\n"); + $code = " runApp($root, ['upgrade', 'plan', 'php', '8.4', '--no-types', '--offline', '--no-color'])[1]; + Assert::contains( + "Mode library, `composer.json` gives no type, which Composer reads as `library`, and there is no `composer.lock`\n", + $plan(), + ); + file_put_contents("$root/composer.lock", '{"packages": []}'); + Assert::contains( + 'Mode application, `composer.json` gives no type, which Composer reads as `library`, yet the project has a `composer.lock` of its own, as an application has; `--library` plans it as a library', + $plan(), + ); + file_put_contents("$root/composer.json", '{"type": "project", "require": {"php": "^8.0"}}'); + Assert::contains("Mode application, `composer.json` gives the type `project`\n", $plan()); +}); + + +test('upgrade plan --save refuses to forget a step a run did not finish, unless told to discard it', function () { + $root = createTempDir('plan-interrupted'); + mkdir("$root/src"); + file_put_contents("$root/composer.json", '{"type": "project", "require": {"php": "^8.0"}}'); + file_put_contents("$root/dresscode.neon", "paths: [src]\ncacheDir: cache\n"); + file_put_contents("$root/src/Code.php", "snapshots->record(1, 'src/Code.php', 'before', 'after'); + + [$exit, , $err] = runApp($root, ['upgrade', 'plan', 'php', '8.4', '--save', '--no-types', '--offline']); + Assert::same(3, $exit); + Assert::contains('Step 1 of a run did not finish and may have rewritten src/Code.php; saving a plan forgets what it kept', $err); + Assert::same(0, runApp($root, ['upgrade', 'plan', 'php', '8.4', '--save', '--discard', '--no-types', '--offline'])[0]); + Assert::same([], new DressCode\Upgrade\RunState($project)->snapshots->findInterrupted()); +}); + + +test('upgrade run goes on after a raise in a new process, which says how far the whole run got', function () { + $root = createTempDir('upgrade-rerun'); + mkdir("$root/src"); + file_put_contents("$root/composer.json", '{"type": "project", "require": {"php": "^8.0"}}'); + file_put_contents("$root/dresscode.neon", "paths: [src]\ncacheDir: cache\n"); + file_put_contents("$root/src/Code.php", " "$root/composer.php"], + ); + Assert::same(0, $exit, $out); + Assert::same(1, substr_count($out, 'Config ')); + Assert::match(<<<'XX' + %A%Step 1 upgrading.php.implicitNullable 1 file + Step 3 upgrading.php.implicitNullable left nothing to fix + UPGRADED %d% steps done, and the final check finds nothing more to upgrade, no tests run + %A% + XX, str_replace("\r", '', $out)); + + // embedded, the command line of the process is not its own to start again + file_put_contents("$root/composer.json", '{"type": "project", "require": {"php": "^8.0"}}'); + putenv("COMPOSER_BINARY=$root/composer.php"); + try { + [$exit, $out] = runApp($root, ['upgrade', 'run', '--yes', '--no-types', '--no-color']); + } finally { + putenv('COMPOSER_BINARY'); + } + + Assert::same(4, $exit); + Assert::contains( + 'Waiting step 2 raised the dependencies, which what runs after it loads anew: run `dresscode upgrade run` again', + $out, + ); +}); + + +test('upgrade abort puts back the files of a step a run did not finish, and says so where nothing is kept', function () { + $root = createTempDir('upgrade-abort'); + mkdir("$root/src"); + file_put_contents("$root/composer.json", '{"type": "project", "require": {"php": "^8.0"}}'); + file_put_contents("$root/dresscode.neon", "paths: [src]\ncacheDir: cache\n"); + file_put_contents("$root/src/Code.php", 'after'); + $project = new DressCode\Upgrade\Project(DressCode\Config\Loader::loadFile("$root/dresscode.neon"), $root, "$root/dresscode.neon"); + new DressCode\Upgrade\RunState($project)->snapshots->record(1, 'src/Code.php', 'before', 'after'); + + // the status and a dry run of abort say what a run keeps, changing nothing + $plan = new DressCode\Upgrade\Plan( + [new DressCode\Upgrade\Target('php', '8.4')], + [new DressCode\Upgrade\FixStep('bridge', ['php'])], + '1.0', + ); + DressCode\Upgrade\PlanFile::write($plan, $root); + [$exit, $out] = runApp($root, ['upgrade', 'status', '--no-color']); + Assert::same(0, $exit); + Assert::match(<<<'XX' + %A%Plan php 8.4 + 1 bridge what is left `upgrade run` fixes and `upgrade tasks` lists + Stopped step 1 did not finish and may have rewritten src/Code.php + Next `dresscode upgrade abort --dry-run` shows what `upgrade abort` puts back, or `dresscode upgrade run --discard` goes on from the code as it is + XX, $out); + [$exit, $out] = runApp($root, ['upgrade', 'abort', '--dry-run', '--no-color']); + Assert::same(0, $exit); + Assert::contains("Restorable src/Code.php\n", $out); + Assert::same('after', file_get_contents("$root/src/Code.php")); + + [$exit, $out] = runApp($root, ['upgrade', 'abort', '--no-color']); + Assert::same(0, $exit); + Assert::contains("Restored src/Code.php\n", $out); + Assert::same('before', file_get_contents("$root/src/Code.php")); + + [$exit, $out] = runApp($root, ['upgrade', 'abort', '--no-color']); + Assert::same(0, $exit); + Assert::contains("Restored nothing, as no run keeps anything to go back\n", $out); +}); + + +test('upgrade estimate --no-types measures without the types the configuration asks for', function () { + $root = createTempDir('estimate-no-types'); + mkdir("$root/src"); + file_put_contents("$root/src/Code.php", "=8.2"}}'); + file_put_contents("$root/src/a.php", $code); + file_put_contents("$root/src/fixtures/data.php", $code); + [$exit, $out] = runApp($root, ['upgrade', 'today', '--no-color']); + Assert::same(0, $exit); + Assert::same($upgraded, file_get_contents("$root/src/a.php")); + Assert::same($code, file_get_contents("$root/src/fixtures/data.php")); // the data of tests are no code + Assert::match( + "%A%Config none, using deprecations\n%A%Upgrading PHP alone, no installed library has upgrading data\n%A%Run `dresscode upgrade today` again: %a%\n", + $out, + ); + + // a file that does not parse may declare what the code calls, which the run says + file_put_contents("$root/src/broken.php", "=8.2"}}'); + file_put_contents("$root/dresscode.neon", "use: [nette]\npaths: [src]\n"); + foreach (['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] as $name) { + file_put_contents("$root/src/$name.php", $code); + } + + [$exit] = runApp($root, ['upgrade', 'today', '--no-color', '--jobs', '2']); + Assert::same(0, $exit); + Assert::same($upgraded, file_get_contents("$root/src/a.php")); + Assert::same($upgraded, file_get_contents("$root/src/h.php")); + Assert::same(1, runApp($root, ['check', '--no-color'])[0]); +}); + + +test('upgrade today leaves out the files a generator wrote and says how many', function () { + $code = "=8.2"}}'); + file_put_contents("$root/dresscode.neon", "paths: [src]\n"); + file_put_contents("$root/src/a.php", $code); + file_put_contents("$root/src/generated.php", $generated); + + [$exit, $out] = runApp($root, ['upgrade', 'today', '--no-color']); + Assert::same(0, $exit); + Assert::same("=8.2"}}'); + file_put_contents("$root/dresscode.neon", "paths: [src]\nseverity:\n\tupgrading.php.deprecatedApi: warning\n"); + file_put_contents("$root/src/a.php", " $args */ + $run = fn(array $args = []) => runApp($root, array_values(['upgrade', 'today', '--no-color', '--no-cache', ...$args]))[0]; + Assert::same(0, $run()); + Assert::same(0, $run(['--max-warnings', '1'])); + Assert::same(1, $run(['--max-warnings', '0'])); +}); + + +test('upgrade today keeps what the project keeps, in an override too, the sets lying beneath its decisions', function () { + $code = "=8.2"}}'); + file_put_contents( + "$root/dresscode.neon", + "paths: [src]\noverrides:\n\t-\n\t\tpaths: [src/kept.php]\n\t\tupgrading: {api: {replacedCalls: {'curl_close(\$handle)': keep}}}\n", + ); + file_put_contents("$root/src/kept.php", $code); + file_put_contents("$root/src/upgraded.php", $code); + + [$exit, , $err] = runApp($root, ['upgrade', 'today', '--no-color']); + Assert::same(0, $exit); + Assert::same('', $err); + Assert::same($code, file_get_contents("$root/src/kept.php")); + Assert::same("=8.2", "acme/lib": "^3.0"}}'); + file_put_contents("$root/dresscode.neon", "paths: [src]\n"); + file_put_contents("$root/src/a.php", " [ + ['name' => 'acme/lib', 'version' => '3.2.0', 'version_normalized' => '3.2.0.0', 'install-path' => '../acme/lib'], + [ + 'name' => 'acme/tool', + 'version' => '1.0.0', + 'version_normalized' => '1.0.0.0', + 'install-path' => '../acme/tool', + 'extra' => ['dresscode' => ['upgrading-dir' => 'upgrading']], + ], + ]], JSON_THROW_ON_ERROR), + ); + + [, $out] = runApp($root, ['upgrade', 'today', '--no-color']); + Assert::match( + "%A%Upgrading acme/tool\nLowest acme/lib 3.2 installed, 3.0 required; `composer update --prefer-lowest` %a%\nReported acme/tool: not required with a lower bound, %a%\n%A%", + $out, + ); +}); + + +test('upgrade today installs the upgrading data a library of the project needs only when asked, or with --yes', function () { + $root = createTempDir('upgrade-data'); + mkdir("$root/src"); + file_put_contents("$root/composer.json", '{"require": {"php": ">=8.2", "nette/utils": "^4.0"}}'); + file_put_contents("$root/dresscode.neon", "paths: [src]\ncacheDir: cache\n"); + file_put_contents("$root/src/a.php", "=8.2", "acme/upgrade": "^1.0"}}'); + file_put_contents("$root/dresscode.neon", "paths: [src]\n"); + file_put_contents("$root/src/a.php", " [[ + 'name' => 'acme/upgrade', + 'version' => '1.0.0', + 'version_normalized' => '1.0.0.0', + 'install-path' => '../acme/upgrade', + 'extra' => ['dresscode' => ['plugin' => 'UpgradePlugin', 'upgrading-dir' => 'upgrading']], + ]]], JSON_THROW_ON_ERROR), + ); + + // one warning for the plugin, none for the map of its rule + Assert::same( + "Warning: Package `acme/upgrade` brings rules that run only where `use` names it, and in `dresscode upgrade`; skipped, with what its upgrading files set for them.\n", + runApp($root, ['check', '--no-color'])[2], + ); + foreach ([[], ['--only', 'acme/upgrade']] as $only) { + [, $out, $err] = runApp($root, ['upgrade', 'today', '--no-color', ...$only]); + Assert::same('', $err); + Assert::contains('A variable of the upgrade.', $out); + } +}); diff --git a/tests/DressCode/Console/Application.workers.phpt b/tests/DressCode/Console/Application.workers.phpt new file mode 100644 index 0000000000..2bfedb7030 --- /dev/null +++ b/tests/DressCode/Console/Application.workers.phpt @@ -0,0 +1,216 @@ + ['trailingWhitespace' => 'forbidden']]);\n", + ); + @mkdir("$root/jobs"); // @ directory may already exist + file_put_contents("$root/jobs/a.php", " ['variables' => 'forbidden']]);\n", + ); + $config = ['--config', "$root/factory.php", '--no-cache']; + [, $expected] = runApp($root, ['check', ...$config, '--jobs', '1']); + [$code, $out, $err] = runApp($root, ['check', ...$config, '--jobs', '2']); + Assert::same('', $err); + Assert::same(1, $code); + Assert::same($expected, $out); + Assert::contains('A variable, built by the factory.', $out); +}); + + +test('a worker gets a value starting with a dash as a value', function () use ($root) { + @unlink("$root/-profile.json"); // @ may not exist + [$code, , $err] = runApp($root, ['check', '--config', "$root/jobs.php", '--no-cache', '--jobs', '2', '--profile=-profile.json']); + Assert::same('', $err); + Assert::same(0, $code); + Assert::true(is_file("$root/-profile.json")); +}); + + +test('a file a worker cannot write fails alone, the others are fixed', function () use ($root) { + file_put_contents( + "$root/readonly.php", + " ['trailingWhitespace' => 'forbidden']]);\n", + ); + @mkdir("$root/readonly"); // @ directory may already exist + file_put_contents("$root/readonly/a.php", " ['typo' => 'forbidden']], paths: ['src']); + PHP); + [$code, , $err] = runApp($root, ['check', '--config', "$root/typo.php", '--no-cache', '--jobs', '2']); + Assert::same(3, $code); + Assert::match('%A%Rule `ConsoleTypo` visits `Acme\Shop\NoSuchNode`, which is no class of a node or a token.%A%', $err); +}); + + +test('a profile counts the same with workers as in the process', function () use ($root) { + file_put_contents( + "$root/profile.php", + " ['keyword' => 'lowercase'],'file' => ['trailingWhitespace' => 'forbidden']]);\n", + ); + @mkdir("$root/profile"); // @ directory may already exist + for ($i = 0; $i < 8; $i++) { + file_put_contents("$root/profile/$i.php", " 0); + Assert::same(8, $profile['rules'][DressCode\Rules\Literals\BuiltinCasingRule::class]['mutating']); + Assert::true($profile['phases']['passes']['time'] >= array_sum(array_column($profile['rules'], 'time'))); + $count = [ + array_map(fn($entry) => [$entry['calls'], $entry['mutating']], $profile['rules']), + array_map(fn($entry) => $entry['calls'], $profile['claims']), + array_map( + fn($entry) => $entry['calls'], + array_intersect_key($profile['phases'], ['parse' => 0, 'passes' => 0, 'print' => 0, 'gaps' => 0]), + ), + ]; + array_walk($count, ksort(...)); // the tables are ordered by time, which differs between runs + $counts[] = $count; + } + + Assert::same($counts[0], $counts[1]); + if (findPhpCliBinary() !== null) { + Assert::count(2 + 1, json_decode(FileSystem::read("$root/profile-2.json"), associative: true)['peakMemory']); // the parent and two workers + } + + // writing a baseline is a run of check too, whether it ends with one or with code a fix would change + runApp($root, ['baseline', '--config', "$root/profile.php", '--profile', "$root/profile-baseline.json"]); + Assert::same(8, json_decode(FileSystem::read("$root/profile-baseline.json"), associative: true)['files']); +}); + + +test('a worker has the settings of the process the ini file alone would not give it', function () use ($root) { + // the configuration turns the rule on by a setting, which a worker has only when it is handed over + file_put_contents( + "$root/ini.php", + " ['trailingWhitespace' => ini_get('user_agent') === 'dresscode-test' ? 'forbidden' : 'keep']]);\n", + ); + @mkdir("$root/ini"); // @ directory may already exist + file_put_contents("$root/ini/a.php", " ['strictArgument' => 'required']]);\n", + ); + $config = ['--config', "$root/jobs.php", '--no-cache']; + + // the risky fixes the configuration does not accept are what a fix leaves + runApp($root, ['baseline', ...$config, '--jobs', '1']); + $single = (string) file_get_contents("$root/jobs-baseline.neon"); + runApp($root, ['baseline', ...$config, '--jobs', '3']); + Assert::same($single, (string) file_get_contents("$root/jobs-baseline.neon")); + Assert::match('%A%decision: mistakes.strictArgument%A%', $single); + + // what the baseline holds is not reported, and the fix the run does not allow is not made + [$code, $out] = runApp($root, ['fix', ...$config, '--jobs', '3']); + Assert::same(0, $code); + Assert::match("%A%OK 3 violations in the baseline in 3 files\n", $out); + Assert::same($a, (string) file_get_contents("$root/jobs/a.php")); + + // once the run allows it, the fix is made, with workers as without them + foreach (['1', '3'] as $jobs) { + file_put_contents("$root/jobs/a.php", $a); + file_put_contents("$root/jobs/c.php", $c); + [$code] = runApp($root, ['fix', ...$config, '--fix-risky', '--jobs', $jobs]); + Assert::same(0, $code); + Assert::same("rerun(['--version'])); + + // a command of its own names the script that carries its default configuration + Assert::exception( + fn() => new DressCode\Console\Application(defaultConfig: new DressCode\Config), + InvalidArgumentException::class, + 'A command of its own giving a default configuration names its script, %a%', + ); + + putenv('DRESSCODE_RERUN=1'); + try { + Assert::null(createRerunContext(STDOUT)->rerun(['--version'])); + } finally { + putenv('DRESSCODE_RERUN'); + } +}); diff --git a/tests/DressCode/Console/EstimatePrinter.phpt b/tests/DressCode/Console/EstimatePrinter.phpt new file mode 100644 index 0000000000..75c666f52f --- /dev/null +++ b/tests/DressCode/Console/EstimatePrinter.phpt @@ -0,0 +1,140 @@ +print($console); + + Assert::contains("Composer acme/mail 4.0: refused by acme/shop v1.0.0 (^3.2)\n", $out); + Assert::contains("Composer php 8.4: nothing in the lock refuses it\n", $out); +}); + + +test('the libraries the data know are broken onto lines that keep the width of the output', function () { + $known = ['acme/application' => '3.3', 'acme/component-model' => '3.2', 'acme/http' => '3.4', 'acme/utils' => '4.1.5', 'php' => '8.6']; + $estimate = new Estimate([Target::php('8.4')], [], 1, new BlindSpots, [], [], $known, true, false, []); + $console = new Console(fopen('php://memory', 'w+') ?: throw new RuntimeException, ColorDepth::None, terminal: false); + $out = new EstimatePrinter($estimate)->print($console); + + Assert::contains( + "Data acme/application up to 3.3, acme/component-model up to 3.2, acme/http up to 3.4,\n" + . " acme/utils up to 4.1.5, php up to 8.6\n", + $out, + ); +}); + + +test('the major moves of the update are said under Moves, those the data do not reach marked, the data listing every move', function () { + $estimate = new Estimate( + [new Target('acme/utils', '4.0')], + [], + 1, + new BlindSpots, + [], + [], + ['acme/utils' => '4.1', 'acme/cache' => '2.1'], + true, + false, + [], + new Moves([ + new Move('acme/utils', 'v3.2.10', 'v4.0.11'), + new Move('acme/http', 'v3.1.0', 'v3.2.0'), + new Move('acme/schema', 'v1.3.0', 'v2.0.0'), + new Move('acme/cache', 'v1.4.0', 'v2.3.0'), + ]), + ); + $console = new Console(fopen('php://memory', 'w+') ?: throw new RuntimeException, ColorDepth::None, terminal: false); + $out = new EstimatePrinter($estimate)->print($console); + + Assert::contains( + "Moves acme/utils v3.2.10 => v4.0.11, acme/schema v1.3.0 => v2.0.0 (no data),\n acme/cache v1.4.0 => v2.3.0 (data up to 2.1)\n", + $out, + ); + Assert::same( + [ + ['package' => 'acme/utils', 'from' => 'v3.2.10', 'to' => 'v4.0.11', 'major' => true, 'data' => true, 'dataUpTo' => '4.1'], + ['package' => 'acme/http', 'from' => 'v3.1.0', 'to' => 'v3.2.0', 'major' => false, 'data' => false, 'dataUpTo' => null], + ['package' => 'acme/schema', 'from' => 'v1.3.0', 'to' => 'v2.0.0', 'major' => true, 'data' => false, 'dataUpTo' => null], + ['package' => 'acme/cache', 'from' => 'v1.4.0', 'to' => 'v2.3.0', 'major' => true, 'data' => false, 'dataUpTo' => '2.1'], + ], + new EstimatePrinter($estimate)->toArray()['moves']['packages'], + ); +}); + + +test('the files the check could not process are said, their places not counted', function () { + $estimate = new Estimate( + [Target::php('8.4')], + [], + 2, + new BlindSpots, + [], + [], + [], + true, + false, + [], + unprocessed: ['src/Broken.php' => 'Syntax error: x'], + ); + $console = new Console(fopen('php://memory', 'w+') ?: throw new RuntimeException, ColorDepth::None, terminal: false); + + Assert::contains( + "Unverified 1 file the check could not process, whose findings are not counted: src/Broken.php\n", + new EstimatePrinter($estimate)->print($console), + ); + Assert::same([['file' => 'src/Broken.php', 'message' => 'Syntax error: x']], new EstimatePrinter($estimate)->toArray()['unverified']); +}); + + +test('the moves Composer could not say are unknown, with the reason', function () { + $estimate = new Estimate( + [new Target('acme/utils', '4.0')], + [], + 1, + new BlindSpots, + [], + [], + [], + true, + false, + [], + new Moves(unknown: 'Composer did not answer in time'), + ); + $console = new Console(fopen('php://memory', 'w+') ?: throw new RuntimeException, ColorDepth::None, terminal: false); + + Assert::contains("Moves unknown, Composer did not answer in time\n", new EstimatePrinter($estimate)->print($console)); +}); + + +test('a library widening its constraints moves nothing, which Composer is not asked about', function () { + $estimate = new Estimate([new Target('acme/utils', '4.0')], [], 1, new BlindSpots, [], [], [], true, false, [], library: true); + $console = new Console(fopen('php://memory', 'w+') ?: throw new RuntimeException, ColorDepth::None, terminal: false); + + Assert::contains( + "Moves nothing, a library widens its constraints with `--no-update`\n", + new EstimatePrinter($estimate)->print($console), + ); + Assert::true(new EstimatePrinter($estimate)->toArray()['library']); +}); diff --git a/tests/DressCode/Console/PlanPrinter.phpt b/tests/DressCode/Console/PlanPrinter.phpt new file mode 100644 index 0000000000..cc541534c9 --- /dev/null +++ b/tests/DressCode/Console/PlanPrinter.phpt @@ -0,0 +1,24 @@ + explode('Tests ', new PlanPrinter( + new Plan([], [], '1.0', testCommand: $test, quickTestCommand: $quick), + )->print($console))[1]; + + Assert::same("not run, no `--test-command` given\n", $tests(null, null)); + Assert::same("`vendor/bin/tester` after every step\n", $tests('vendor/bin/tester', null)); + Assert::same("`vendor/bin/tester unit` after every step\n", $tests(null, 'vendor/bin/tester unit')); + Assert::same( + "`vendor/bin/tester unit` after every step, `vendor/bin/tester` after a raise and the last one\n", + $tests('vendor/bin/tester', 'vendor/bin/tester unit'), + ); +}); diff --git a/tests/DressCode/Console/ProgressBar.phpt b/tests/DressCode/Console/ProgressBar.phpt index d3c996c8ad..45a738fe2d 100644 --- a/tests/DressCode/Console/ProgressBar.phpt +++ b/tests/DressCode/Console/ProgressBar.phpt @@ -40,7 +40,7 @@ test('a run over before the delay draws nothing', function () { }); -test('the bar shows the share done and the file that takes long', function () { +test('the bar shows the share done, and the file that takes long with a spinner beside it', function () { $stream = stream(); $bar = bar($stream); usleep(350_000); @@ -49,7 +49,7 @@ test('the bar shows the share done and the file that takes long', function () { usleep(150_000); $bar->advance(6, ['src/slow.php' => microtime(as_float: true) - 3]); - Assert::contains('6/10 src/slow.php 3s', read($stream)); + Assert::match("%A%6/10 src/slow.php %a%\r", read($stream)); }); diff --git a/tests/DressCode/Console/RiskReview.phpt b/tests/DressCode/Console/RiskReview.phpt index 9092ffaab3..54022b7cc1 100644 --- a/tests/DressCode/Console/RiskReview.phpt +++ b/tests/DressCode/Console/RiskReview.phpt @@ -56,7 +56,7 @@ function review(array $files, string $answers, ?Closure $meanwhile = null): arra $factory->resolve(new Config(paths: ['src'], decisions: ['mistakes' => ['strictArgument' => 'required']]), $root), cache: false, ); - $result = $runner->run($runner->findFiles(['src']), true, new NullReporter); + $result = $runner->run($runner->files->findFiles(['src']), true, new NullReporter); $output = fopen('php://memory', 'w+') ?: throw new RuntimeException; MeanwhileFilter::$meanwhile = $meanwhile === null ? null : fn() => $meanwhile($root); stream_filter_append($output, 'meanwhile', STREAM_FILTER_WRITE); diff --git a/tests/DressCode/Console/StatusLine.phpt b/tests/DressCode/Console/StatusLine.phpt new file mode 100644 index 0000000000..9fce5b6ff1 --- /dev/null +++ b/tests/DressCode/Console/StatusLine.phpt @@ -0,0 +1,27 @@ +show('Measuring', 'measuring 3 decisions'); + $status->advance(1, 4); + rewind($stream); + Assert::match( + "%A%Measuring measuring 3 decisions…\r%A%Measuring measuring 3 decisions… %a% 1/4\r", + (string) stream_get_contents($stream), + ); +}); diff --git a/tests/DressCode/Console/TasksPrinter.phpt b/tests/DressCode/Console/TasksPrinter.phpt new file mode 100644 index 0000000000..4c4c1e57cc --- /dev/null +++ b/tests/DressCode/Console/TasksPrinter.phpt @@ -0,0 +1,51 @@ + new Task( + 'src/Order.php', + new Violation( + $decision, + 'Write it anew.', + 3, + 1, + Severity::Error, + 'x', + fixable: $risk !== null, + risk: $risk, + refused: $risk !== null, + because: $because, + ), + ); + $printer = new TasksPrinter(['php' => [ + $place('upgrading.php.deprecatedApi', null), + $place('upgrading.syntax.match', Risk::BehaviorChanges, 'a case compares loosely'), + $place('upgrading.api.replacedMembers', Risk::TypeUnknown), + ]]); + $console = new Console(fopen('php://memory', 'w+') ?: throw new RuntimeException, ColorDepth::None, terminal: false); + + Assert::match( + <<<'XX' + Manual 3 tasks the fixes left + src/Order.php:3 Write it anew. [upgrading.php.deprecatedApi] + src/Order.php:3 Write it anew. [upgrading.syntax.match] + its fix is risky, as a case compares loosely; once you checked it, `fixRisky: [upgrading.syntax.match]` allows it + src/Order.php:3 Write it anew. [upgrading.api.replacedMembers] + its fix needs the type of the expression, which the analysis does not know; where the types are off, `--types` turns them on + + XX, + $printer->print($console), + ); + Assert::same( + [[false, null, null], [true, 'behaviorChanges', 'a case compares loosely'], [true, 'typeUnknown', null]], + array_map(fn(array $task) => [$task['fixable'], $task['risk'], $task['because']], $printer->toArray()['tasks']), + ); +}); diff --git a/tests/DressCode/Console/explain.phpt b/tests/DressCode/Console/explain.phpt index 95c6c8bd7e..0ef7cf26b4 100644 --- a/tests/DressCode/Console/explain.phpt +++ b/tests/DressCode/Console/explain.phpt @@ -110,7 +110,10 @@ test('explain of a decision writes what it is, its value here and those of the s Assert::same(0, $code); Assert::contains('`spacing.call`', $text); Assert::contains("Here `'foo(\$a, \$b)'`, set by dresscode/psr12.", $text); - Assert::contains("The standards: perCs `'foo(\$a, \$b)'`, psr12 `'foo(\$a, \$b)'`, nette `'foo(\$a, \$b)'`.", $text); + Assert::contains( + "The standards: perCs `'foo(\$a, \$b)'`, psr12 `'foo(\$a, \$b)'`, nette `'foo(\$a, \$b)'`, symfony `'foo(\$a, \$b)'`, laravel `'foo(\$a, \$b)'`.", + $text, + ); Assert::contains('See https://dresscode.run/decisions/spacing.call', $text); }); diff --git a/tests/DressCode/Console/fixtures/upgrade-plugin.php b/tests/DressCode/Console/fixtures/upgrade-plugin.php new file mode 100644 index 0000000000..bb03ab4bed --- /dev/null +++ b/tests/DressCode/Console/fixtures/upgrade-plugin.php @@ -0,0 +1,39 @@ +report($node, 'A variable of the upgrade.', fixable: false); + } +} + + +final class UpgradePlugin implements Plugin +{ + public function getManifest(): PluginManifest + { + return new PluginManifest(rules: [UpgradeVariableRule::class], section: 'acmeUpgrade'); + } +} diff --git a/tests/DressCode/Console/init.existing.phpt b/tests/DressCode/Console/init.existing.phpt new file mode 100644 index 0000000000..d977d7fb93 --- /dev/null +++ b/tests/DressCode/Console/init.existing.phpt @@ -0,0 +1,147 @@ + $tabbed, 'dresscode.php.dist' => " $tabbed, 'dresscode.neon' => "use: [nette]\n", 'dresscode.php' => " '{}', + 'src/A.php' => $tabbed, + 'src/B.php' => $tabbed, + 'dresscode.neon' => "use: perCs\npaths: [src]\n", + ]); + [$code, $out] = runInit($root); + Assert::same(0, $code); + Assert::contains('indentation.unit: `dresscode.neon` has 4 spaces, a fix of it rewrites 2 files, init now writes tab', $out); +}); + + +test('over a configuration that exists init says what it sets apart from the standard while the code holds to the standard', function () { + $spaced = " '{}', + 'src/a.php' => $spaced, + 'src/b.php' => $spaced, + 'dresscode.neon' => "use: perCs\npaths: [src]\nindentation:\n\tunit: tab\n", + ]); + [$code, $out] = runInit($root, ['--yes']); + Assert::same(0, $code); + Assert::match('%A%Moved indentation.unit: `dresscode.neon` has tab, the code holds to %a% of the standard%A%', $out); + Assert::notContains('Moved nothing', $out); + // as data, what moved is the point of the document + $data = json_decode(runInit($root, ['--format', 'json'])[1], associative: true, flags: JSON_THROW_ON_ERROR); + Assert::same( + [[ + 'kind' => 'heldToStandard', + 'path' => 'indentation.unit', + 'current' => 'tab', + 'measured' => '4 spaces', + 'cost' => null, + 'comment' => '', + ]], + $data['shifts'], + ); + + // the files the configuration checks are compared, those of an override apart, which init says + file_put_contents( + "$root/dresscode.neon", + "use: perCs\npaths: [src]\noverrides:\n\t-\n\t\tpaths: [src/b.php]\n\t\tindentation:\n\t\t\tunit: tab\n", + ); + [$code, $out] = runInit($root, ['--yes']); + Assert::same(0, $code); + Assert::contains('comparing the code of', $out); + Assert::contains( + 'Compared the code with the standard and the decisions of `dresscode.neon`, over the files it checks, but 1 file an override of it gives decisions of their own', + $out, + ); +}); + + +test( + 'over a configuration that exists the code is compared with the presets a plugin it declares itself brings', + function () use ($tabbed) { + $root = createProject('existing-plugin', [ + 'composer.json' => '{}', + 'src/A.php' => $tabbed, + 'style.neon' => "use: perCs\n", + 'dresscode.php' => <<<'XX' + __DIR__ . '/style.neon']); + } + } + + return new DressCode\Config(use: [new InitComparedPlugin, 'review/style'], paths: ['src']); + XX, + ]); + // the class exists in this process alone, which the probes then run in + [$code, $out, $err] = runInit($root, jobs: null); + Assert::same('', $err); + Assert::same(0, $code); + Assert::contains('indentation.unit: `dresscode.php` has 4 spaces, a fix of it rewrites 1 file, init now writes tab', $out); + }, +); + + +test('over a configuration that exists init asks nothing in a terminal and takes no answer, as it writes nothing', function () { + $asked = createProject('existing-asked', stragglers() + ['dresscode.neon' => "use: perCs\npaths: [src]\n"]); + [$code, $out] = runInit($asked, [], "2\n"); + Assert::same(0, $code); + Assert::notContains('Your choice', $out); + Assert::notContains('Your call', $out); + Assert::contains("\nMoved", $out); + $moved = substr($out, (int) strpos($out, "\nMoved")); + + $piped = createProject('existing-piped', stragglers() + ['dresscode.neon' => "use: perCs\npaths: [src]\n"]); + [, $out] = runInit($piped); + Assert::same($moved, substr($out, (int) strpos($out, "\nMoved"))); + + // one init cannot read is no reason to ask for the standard either, and --set has nothing to answer + $broken = createProject('existing-broken', stragglers() + ['dresscode.neon' => "use: [\n"]); + [$code, $out] = runInit($broken, [], "2\n"); + Assert::same(3, $code); + Assert::notContains('which standard should it follow?', $out); + [$code, , $err] = runInit($asked, ['--set', 'indentation.unit=tab']); + Assert::same(3, $code); + Assert::contains( + '`--set` answers what init asks, and over `dresscode.neon`, which exists, it asks nothing; edit the file instead.', + $err, + ); +}); diff --git a/tests/DressCode/Console/init.formatter.phpt b/tests/DressCode/Console/init.formatter.phpt new file mode 100644 index 0000000000..225c88cd56 --- /dev/null +++ b/tests/DressCode/Console/init.formatter.phpt @@ -0,0 +1,129 @@ + $tabbed, + '.php-cs-fixer.dist.php' => "setFinder(PhpCsFixer\\Finder::create()->in(\$dirs));\n", + ]); + [, $out] = runInit($root, ['--use', 'perCs']); + Assert::contains('its scope may differ, as `in($dirs)` could not be read', $out); +}); + + +test('of several formatters nothing tells apart the first is taken, the others said', function () use ($tabbed) { + $root = createProject('formatter-several', [ + 'src/A.php' => $tabbed, + 'phpcs.xml' => '', + 'pint.json' => '{}', + ]); + [, $out] = runInit($root, ['--use', 'perCs']); + Assert::match( + "%A%Formatter PHP_CodeSniffer, `phpcs.xml`\n%s%taken as the first, the project having also `pint.json` of Laravel Pint; a terminal asks which, without `--yes`\n%A%", + $out, + ); + + // one named is no guess among them + [, $out] = runInit($root, ['--use', 'perCs', '--dry-run', '--formatter', 'pint.json']); + Assert::contains("Formatter Laravel Pint, `pint.json`\n", $out); + Assert::notContains('taken as the first', $out); +}); + + +test('init proposes the standard of the preset Laravel Pint formats with, as the package or its configuration says', function () { + foreach ([ + 'package' => ['composer.lock' => '{"packages": [], "packages-dev": [{"name": "laravel/pint"}]}'], + 'configuration' => ['pint.json' => '{"preset": "laravel"}'], + ] as $name => $files) { + $root = createProject( + "pint-$name", + ['src/a.php' => " '{"autoload": {"psr-4": {"App\\\\": "src"}}}', ...$files], + ); + [$code, $out] = runInit($root); + Assert::same(0, $code); + Assert::contains('Standard laravel (the style of Laravel Pint), Laravel Pint formats the project in this style', $out); + Assert::contains( + 'use: laravel # Laravel Pint formats the project in this style', + (string) file_get_contents("$root/dresscode.neon"), + ); + } + + // another preset of Pint is its standard, and one no standard is leaves it to the code, which names no standard to win a tie + foreach (['per' => 'perCs', 'psr12' => 'psr12', 'symfony' => 'symfony', 'empty' => 'perCs'] as $preset => $standard) { + $root = createProject( + "pint-$preset", + ['src/a.php' => " "{\"preset\": \"$preset\"}", 'composer.json' => '{}'], + ); + [, $out] = runInit($root); + Assert::contains("Standard $standard (", $out); + } + Assert::contains('the nearest to your code', $out); + + // a standard given wins + $root = createProject('pint-given', ['src/a.php' => " '{}']); + runInit($root, ['--use', 'nette']); + Assert::contains("use: nette\n", (string) file_get_contents("$root/dresscode.neon")); +}); + + +test( + 'the configuration of another formatter is read as text and never run, and what it leaves out init leaves out too', + function () use ($tabbed) { + $root = createProject('foreign', [ + 'composer.json' => '{"scripts": {"cs": "php-cs-fixer fix"}}', + '.php-cs-fixer.dist.php' => "setRules(['@PSR12' => true])\n" + . "\t->setFinder(PhpCsFixer\\Finder::create()->in(__DIR__ . '/src')->exclude('Generated'));\n", + 'src/A.php' => $tabbed, + 'src/S.php' => " " '{}', + 'phpcs.xml' => '' + . 'src/legacy/', + 'src/A.php' => $camel, + 'src/legacy/B.php' => $snake, + 'src/legacy/C.php' => str_replace('B', 'C', $snake), + ]); + [$code, $out] = runInit($root, ['--use', 'psr12']); + Assert::same(0, $code); + Assert::contains('Formatter PHP_CodeSniffer, `phpcs.xml`', $out); + Assert::match( + "%A%overrides:\n\t# where PSR1.Methods.CamelCapsMethodName.NotCamelCaps is left out by your formatter\n\t-\n\t\tpaths:\n\t\t\t- src/legacy\n\t\tnaming:\n\t\t\tmethod: keep\n", + (string) file_get_contents("$root/dresscode.neon"), + ); + Assert::notContains('baseline', (string) file_get_contents("$root/dresscode.neon")); +}); diff --git a/tests/DressCode/Console/init.helpers.php b/tests/DressCode/Console/init.helpers.php new file mode 100644 index 0000000000..8a49d11f48 --- /dev/null +++ b/tests/DressCode/Console/init.helpers.php @@ -0,0 +1,57 @@ + $files + */ +function createProject(string $name, array $files): string +{ + $root = createTempDir($name); + foreach ($files as $path => $content) { + @mkdir(dirname("$root/$path"), recursive: true); // @ directory may already exist + file_put_contents("$root/$path", $content); + } + + return $root; +} + + +/** + * @param list $args + * @param ?string $answers what the user types in a terminal; null for no terminal + * @param ?int $jobs 1 keeps the probes in the process, where they cost a fraction; null starts them as a user's run does + * @return array{int, string, string} + */ +function runInit(string $root, array $args = [], ?string $answers = null, ?int $jobs = 1): array +{ + $out = fopen('php://memory', 'w+') ?: throw new RuntimeException; + $err = fopen('php://memory', 'w+') ?: throw new RuntimeException; + $in = fopen('php://memory', 'w+') ?: throw new RuntimeException; + fwrite($in, $answers ?? ''); + rewind($in); + if ($jobs !== null) { + $args = [...$args, '--jobs', (string) $jobs]; + } + + $code = new Application($out, $err, $in, cwd: $root, interactive: $answers !== null)->run(['dresscode', 'init', ...$args]); + rewind($out); + rewind($err); + return [$code, (string) stream_get_contents($out), (string) stream_get_contents($err)]; +} + + +/** @return array twenty files, three of them without the blank line the others have after the tag */ +function stragglers(): array +{ + $files = ['composer.json' => '{}']; + foreach (range(1, 20) as $i) { + $files[sprintf('src/f%02d.php', $i)] = $i <= 3 ? " $tabbed, + 'src/B.php' => str_replace('A', 'B', $tabbed), + 'src/C.php' => " " " '{}', // the project of its own, not the one the temporary directory lies in + ]); + [$code, $out] = runInit($root); + Assert::same(0, $code); + Assert::match(<<<'XX' + DRESS|CODE %a% looking at %a% to write its `dresscode.neon` + + Project 3 PHP files in src, tests + Excluded fixtures test data + src/C.php generated + PHP 8.0, `composer.json` names none + Standard perCs (PER Coding Style), the nearest to your code by the files a fix changes, 3 under psr12; `--use` names another + Measured your code departs from it in 1 decision over 3 files; perCs as it is, with no decision below `use`, would rewrite 3 of them + As written indentation.unit: tab your code, all 3 files + Checked all 3 files: a fix changes no file + Your call `dresscode init --use nette` measures against nette instead, where there is no `dresscode.neon` yet + + Written `dresscode.neon` + Next `dresscode check` passes already + + XX, $out); + + $neon = (string) file_get_contents("$root/dresscode.neon"); + // the line endings init writes, whatever the checkout gave the heredoc + Assert::same(str_replace("\r\n", "\n", <<<'XX' + # made to measure by `dresscode init`; run it again to see what has moved since, it writes nothing over this file + use: perCs # PER Coding Style, of the standards the one a fix changes the fewest files of your code under + + paths: [src, tests] + excludePaths: + - fixtures # test data + - src/C.php # generated + fileExtensions: [php, phpt] + + # the namespaces of the scope declare no function and no constant, so an unqualified name in a namespace + # is resolved for certain; one declared later is reported until it is listed + nameResolution: certain + + indentation: + unit: tab # your code, all 3 files + + XX), $neon); + Assert::notContains('fixRisky', $neon); + + // the round trip: what the run resolves the written file to is what was measured + $factory = new RunnerFactory; + $resolved = $factory->resolve(Loader::loadFile("$root/dresscode.neon"), $root)->resolvedConfig; + Assert::same("\t", $resolved->indent); + Assert::noError( + fn() => Proposal::measure( + ProjectFacts::detect($root), + Scope::fromAutoload($root, Scope::Extensions), + new InProcessProbeRunner($root), + ) + ->toDraft() + ->checkResolution($resolved), + ); + + // and the configuration is one a run takes, and passes, the generated file out + $out = fopen('php://memory', 'w+') ?: throw new RuntimeException; + $check = new Application($out, $out, cwd: $root)->run(['dresscode', 'check', '--no-cache', '--jobs', '1']); + rewind($out); + Assert::same(0, $check, (string) stream_get_contents($out)); +}); + + +test('a value init writes is shown as the file writes it, a map and an example among them', function () use ($tabbed) { + $root = createProject('values-written', ['src/A.php' => $tabbed]); + [$code, $out] = runInit( + $root, + ['--use', 'perCs', '--set', 'blankLines.beforeStatement={return: 1}', '--set', "spacing.anonymousFunction='function(\$x)'"], + ); + Assert::same(0, $code); + Assert::contains('blankLines.beforeStatement: {return: 1}', $out); + Assert::contains("spacing.anonymousFunction: 'function(\$x)'", $out); +}); + + +test('init lists what the namespaces declare from every file', function () { + $root = createProject('namespaced', [ + 'src/helpers.php' => " " " ' '{"autoload": {"psr-4": {"App\\\\": "src"}}}', + 'composer.lock' => '{"packages": [{"name": "symfony/dependency-injection"}], "packages-dev": []}', + 'vendor/composer/installed.json' => '{"packages": [{"name": "symfony/dependency-injection", "version": "7.3.0", "install-path": "../symfony/dependency-injection"}]}', + ]); + [$code, $out] = runInit($root, ['--use', 'perCs']); + Assert::same(0, $code); + Assert::match(<<<'XX' + %A%use: perCs + %A% + # the namespaces of the scope declare these functions and constants and no others, so an unqualified name + # in a namespace is resolved for certain; one declared later is reported until it is listed + nameResolution: certain + namespaces: + functions: + - App\Compat\first + - App\helper + - App\One\one + constants: + - App\BOOTED + - App\LIMIT + - App\One\DEFINED + %A% + XX, (string) file_get_contents("$root/dresscode.neon")); + + $factory = new RunnerFactory; + $resolved = $factory->resolve(Loader::loadFile("$root/dresscode.neon"), $root)->resolvedConfig; + Assert::same('certain', $resolved->nameResolution); + Assert::same(['dresscode/psr12', 'dresscode/perCs'], $resolved->use); + Assert::same('the configuration', $resolved->namespacedFunctions['App\Compat\first']); + Assert::same( + 'DressCode for symfony/dependency-injection', + $resolved->namespacedFunctions['Symfony\Component\DependencyInjection\Loader\Configurator\service'], + ); + Assert::true($resolved->findRule(DressCode\Rules\Namespaces\NoUnlistedNamespacedDeclarationsRule::class)?->isActive()); +}); + + +test('init turns the types on where the project has PHPStan, and says why', function () { + $root = createProject('typed', [ + 'src/a.php' => " '{"autoload": {"psr-4": {"App\\\\": "src"}}}', + 'composer.lock' => '{"packages": [], "packages-dev": [{"name": "phpstan/phpstan"}]}', + ]); + [$code, $out] = runInit($root, ['--use', 'perCs']); + Assert::same(0, $code); + // the measuring goes without them, so the configuration written is checked with them once more + Assert::contains( + "Types checked once more with the types of the code, which the counts above go without: the first fix rewrites no file and leaves 0 findings then\n", + $out, + ); + Assert::contains( + "\ntypeAnalysis: phpstan # phpstan/phpstan is installed, so the rules get the types of the code\n", + (string) file_get_contents("$root/dresscode.neon"), + ); + Assert::same('phpstan', Loader::loadFile("$root/dresscode.neon")->typeAnalysis); + + $root = createProject('untyped', ['src/a.php' => " '{"autoload": {"psr-4": {"App\\\\": "src"}}}']); + runInit($root, ['--use', 'perCs']); + Assert::notContains('typeAnalysis', (string) file_get_contents("$root/dresscode.neon")); +}); + + +test('init --dry-run prints the configuration and writes nothing, and --formatter names the configuration of the formatter', function () { + $root = createProject('dry-run', [ + 'src/a.php' => " '', + 'conf/pint.json' => '{"preset": "per"}', + ]); + [$code, $out] = runInit($root, ['--dry-run', '--formatter', 'conf/pint.json']); + Assert::same(0, $code); + Assert::contains("Proposed `dresscode.neon` below, nothing written, as `--dry-run` asks\n", $out); + Assert::match("%A%\nuse: perCs # %A%", $out); + Assert::false(is_file("$root/dresscode.neon")); +}); + + +test('more presets than one are written as a list', function () use ($tabbed) { + $root = createProject('presets', ['src/A.php' => $tabbed]); + [$code] = runInit($root, ['--use', 'psr12', '--use', 'perCs']); + Assert::same(0, $code); + Assert::contains("use:\n\t- psr12\n\t- perCs\n\n", (string) file_get_contents("$root/dresscode.neon")); +}); + + +test('a decision the code does not make clearly is not written as a value', function () { + $root = createProject('undecided', [ + 'src/a.php' => " "resolve(Loader::loadFile("$root/dresscode.neon"), $root)->resolvedConfig; + Assert::false($resolved->findRule(DressCode\Rules\Literals\StringQuotesRule::class)?->isActive()); + Assert::same("\t", $resolved->indent); // new code is indented by a tab where nothing decides it +}); + + +test('the unit of the indentation is measured alone, whatever levels the other constructs step in by', function () { + $switch = " $switch, 'src/b.php' => $switch]); + [$code, $out] = runInit($root); + Assert::same(0, $code); + Assert::contains("As written indentation.unit: tab your code, all 2 files\n", $out); +}); + + +test('the shape of the conditions is counted by condition, and the shapes the code has pass where none prevails', function () { + $perLine = "if (\n\t\$a\n\t&& \$b\n) {\n}\n"; + $compact = "if (\$a\n\t&& \$b\n) {\n}\n"; + $attached = "if (\$a\n\t&& \$b) {\n}\n"; + $root = createProject('shapes', [ + 'src/a.php' => " "resolve(Loader::loadFile("$root/dresscode.neon"), $root)->resolvedConfig; + Assert::same(['apart', 'closingApart'], $resolved->decisions['multiline.edges.condition']->value->getWords()); + + // a shape at least 70 % of the conditions have is written as the shape + $root = createProject('shape', ['src/a.php' => " " '{}']; + foreach (range(1, 20) as $i) { + $files[sprintf('src/f%02d.php', $i)] = $i <= 3 ? " '{}']; + foreach (range(1, 20) as $i) { + $files["src/A$i.php"] = str_replace('class A', "class A$i", $tabbed); + } + + foreach (range(1, 6) as $i) { + $files["tests/T$i.php"] = str_replace(['class A', "\t"], ["class T$i", ' '], $tabbed); + } + + [$code, $out] = runInit(createProject('region', $files), ['--use', 'perCs', '--dry-run']); + Assert::same(0, $code); + Assert::contains("indentation.unit: tab your code, 77% of 26 files\n", $out); + Assert::contains( + "Differs tests: indentation.unit 4 spaces in all 6 files; an override for `tests` would keep it, none is written\n", + $out, + ); + Assert::notContains('overrides', $out); + }, +); diff --git a/tests/DressCode/Console/init.questions.phpt b/tests/DressCode/Console/init.questions.phpt new file mode 100644 index 0000000000..81904b154d --- /dev/null +++ b/tests/DressCode/Console/init.questions.phpt @@ -0,0 +1,228 @@ +inner->run($probes); + $config = $probes['scope'] ?? []; + if ( + isset($results['scope']) + && ($config['blankLines']['afterOpeningTag'] ?? null) === $this->afterOpeningTag + && ($config['braces']['position']['class'] ?? null) !== 'keep' + ) { + $result = $results['scope']; + $files = ['src/f10.php' => true, 'src/f11.php' => true, 'src/f12.php' => true]; + $results['scope'] = new ProbeResult( + $result->places, + ['braces.position.class' => $files] + $result->fixable, + $result->left, + $result->changedFiles, + $result->syntaxErrors, + $result->failed, + ); + } + + return $results; + } + }; +} + + +test('a decision left unchecked that makes another one cost more than the budget leaves that one unchecked too', function () { + $root = createProject('cascade', stragglers()); + $runner = classBracesCost($root, 'keep'); + $proposal = Proposal::measure(ProjectFacts::detect($root), Scope::fromAutoload($root, Scope::Extensions), $runner, ['perCs']); + $proposal->verify(); + Assert::same(ValueOrigin::OverBudget, $proposal->values['blankLines.afterOpeningTag']->origin); + Assert::same(ValueOrigin::OverBudget, $proposal->values['braces.position.class']->origin); + Assert::same(3, $proposal->values['braces.position.class']->cost); +}); + + +test('an answer that checks a decision may leave another one over the budget, which the next round asks about', function () { + $root = createProject('rounds', stragglers()); + $runner = classBracesCost($root, 1); + $proposal = Proposal::measure(ProjectFacts::detect($root), Scope::fromAutoload($root, Scope::Extensions), $runner, ['perCs']); + $proposal->verify(); + $questions = new Questions; + [$first] = $questions->findNext($proposal); + Assert::contains('`blankLines.afterOpeningTag: 1`', $first->text); + + Assert::true($proposal->applyAnswers($first->answers[1])); + $proposal->verify(); + $next = $questions->findNext($proposal); + Assert::count(1, $next); + Assert::contains('`braces.position.class: nextLine`', $next[0]->text); + + // the recommended answer changes nothing, so there is nothing to check again and nothing more to ask + Assert::false($proposal->applyAnswers($next[0]->getRecommended())); + Assert::same([], $questions->findNext($proposal)); +}); + + +test('in a terminal init asks where the measuring left the choice, and Enter takes what --yes takes', function () { + $asked = createProject('asked', stragglers()); + [$code, $out] = runInit($asked, ['--use', 'perCs'], "\n"); + Assert::same(0, $code); + Assert::contains("Check `blankLines.afterOpeningTag: 1`? A fix rewrites 3 files, more than the budget allows.\n", $out); + Assert::contains('e.g. src/f01.php:1 ', $out); + Assert::contains(" [1] don't check it (recommended)\n [2] 1 everywhere, a fix rewrites 3 files\n", $out); + Assert::notContains('keep the style made to measure?', $out); // what the standard as it is rewrites is said, not asked + + $yes = createProject('yes', stragglers()); + [$code, $out] = runInit($yes, ['--use', 'perCs', '--yes'], "2\n"); + Assert::same(0, $code); + Assert::notContains('[1]', $out); + Assert::same(file_get_contents("$yes/dresscode.neon"), file_get_contents("$asked/dresscode.neon")); + + // the answer checks the decision, and the configuration is checked again with it + $answered = createProject('answered', stragglers()); + [$code, $out] = runInit($answered, ['--use', 'perCs'], "2\n"); + Assert::same(0, $code); + Assert::contains("\nChecked all 20 files: a fix changes 3 files\n", $out); + Assert::contains("\tafterOpeningTag: 1 # your answer\n", (string) file_get_contents("$answered/dresscode.neon")); +}); + + +test('a close call of standards is asked first, and the answer measures against the other', function () { + $root = createProject('close', stragglers()); + [$code, $out] = runInit($root, [], "2\n\n"); + Assert::same(0, $code); + Assert::match('%A%Your code is as near to %a% as to %a%; which standard should it follow?%A%', $out); + Assert::notSame('perCs', Loader::loadFile("$root/dresscode.neon")->use[0] ?? null); + + // without a terminal the nearer is taken, and the line says how to measure against the other + $root = createProject('close-pipe', stragglers()); + [, $out] = runInit($root); + Assert::match('%A%Your call `dresscode init --use %a%` measures against %a% instead, where there is no `dresscode.neon` yet%A%', $out); +}); + + +test('answers adding to one list in a round add up, neither replacing the other', function () { + $root = createProject('answers-list', ['composer.json' => '{}', 'src/a.php' => "applyAnswers(new Answer('one', ['naming.except' => ['~a~']]), new Answer('other', ['naming.except' => ['~b~']])); + Assert::same(['~a~', '~b~'], $proposal->values['naming.except']->value); +}); + + +test('names no rule renames that share a suffix go to the baseline, or are let pass by naming.except', function () { + $files = ['composer.json' => '{}']; + foreach (['Cart_', 'Order_', 'Item_', 'Stock_'] as $class) { + $files["src/$class.php"] = " '{}', + 'src/Limits.php' => " '{}', + 'phpcs.xml' => '', + ]; + foreach (range(1, 4) as $i) { + $files["src/C$i.php"] = $tabbed; + } + + $root = createProject('contradicted', $files); + [$code, $out] = runInit($root, ['--use', 'perCs']); + Assert::same(0, $code); + Assert::contains('`indentation.unit: 4 spaces` in `dresscode.neon` says what `phpcs.xml` says; its fix changes 4 files', $out); + Assert::contains("\tunit: tab", (string) file_get_contents("$root/dresscode.neon")); + + // the answer that keeps the value of the code writes it as the user's, with what it rests on + $root = createProject('contradicted-kept', $files); + [$code, $out] = runInit($root, ['--use', 'perCs'], "1\n"); + Assert::same(0, $code); + Assert::contains('[1] tab, as your code writes it (recommended)', $out); + Assert::match("%A%\tunit: tab %s%# your code, all 4 files\n%A?%", (string) file_get_contents("$root/dresscode.neon")); +}); + + +test('for a tool init takes the recommended answers and writes the proposal as data, nothing else', function () { + $root = createProject('agent', stragglers()); + [$code, $out] = runInit($root, ['--use', 'perCs', '--format', 'json'], "2\n"); + Assert::same(0, $code); + $data = json_decode($out, associative: true, flags: JSON_THROW_ON_ERROR); + Assert::same(DressCode\Console\InitPrinter::Version, $data['version']); + Assert::same(['perCs'], $data['standard']['use']); + Assert::match('`blankLines.afterOpeningTag: 1` in `dresscode.neon` checks it too%a%', $data['instructions'][0]); + Assert::same('dresscode.neon', $data['written']); + Assert::same($data['configuration'], (string) file_get_contents("$root/dresscode.neon")); + Assert::contains("\tafterOpeningTag: keep", $data['configuration']); +}); + + +test('a question is numbered in its round where the round has more, and a round the answers left says so', function () { + $printer = new InitPrinter(new Console(fopen('php://memory', 'w+') ?: throw new RuntimeException, ColorDepth::None)); + $question = new Question('Check it?', [new Answer("don't check it"), new Answer('check it', ['spacing.cast' => 'none'])], ''); + Assert::same("(2/3) Check it?\n [1] don't check it (recommended)\n [2] check it\n", $printer->printQuestion($question, 2, 3)); + Assert::same("Check it?\n [1] don't check it (recommended)\n [2] check it\n", $printer->printQuestion($question)); + Assert::same("Your answers leave one more question:\n", $printer->printNextRound(1)); + Assert::same("Your answers leave 2 more questions:\n", $printer->printNextRound(2)); +}); diff --git a/tests/DressCode/Console/init.scope.phpt b/tests/DressCode/Console/init.scope.phpt new file mode 100644 index 0000000000..e5b7964fa3 --- /dev/null +++ b/tests/DressCode/Console/init.scope.phpt @@ -0,0 +1,184 @@ + " " " " " new ProbeResult( + $result->places, + $result->fixable, + $result->left, + $result->changedFiles, + $result->syntaxErrors, + ['src/b.php'], + ), $this->inner->run($probes)); + } + }; + $scope = Scope::fromAutoload($root, Scope::Extensions); + $proposal = Proposal::measure(ProjectFacts::detect($root), $scope, $runner, ['perCs']); + $proposal->verify(); + Assert::same(['src/b.php'], $proposal->findFailedFiles()); + Assert::match( + "# made to measure %a%\n# not measured: a rule failed in src/b.php, so the decisions rest on the other files\n%A%", + $proposal->toDraft()->toNeon(), + ); + + // a rule failing under every standard there is to choose from leaves none to choose + $proposal = Proposal::measure(ProjectFacts::detect($root), Scope::fromAutoload($root, Scope::Extensions), $runner); + Assert::true($proposal->standard->unsure); +}); + + +test('a project without a PHP file to measure is told so, and nothing is written', function () { + $root = createProject('empty-project', ['src/readme.txt' => 'nothing']); + [$code, , $err] = runInit($root, ['--yes']); + Assert::same(3, $code); + Assert::contains('The project has no PHP file to measure in', $err); + Assert::false(is_file("$root/dresscode.neon")); + + // over a configuration whose overrides give every file decisions of their own, that is what init says + $root = createProject('overridden-project', [ + 'src/A.php' => " "use: perCs\npaths: [src]\noverrides:\n\t-\n\t\tpaths: [src]\n\t\tindentation:\n\t\t\tunit: tab\n", + ]); + [$code, $out, $err] = runInit($root); + Assert::same([2, ''], [$code, $err]); + Assert::contains('Compared nothing, as an override of `dresscode.neon` gives every file it checks decisions of their own', $out); + $data = json_decode(runInit($root, ['--format', 'json'])[1], associative: true, flags: JSON_THROW_ON_ERROR); + Assert::same(['version', 'root', 'scope'], array_keys($data)); + Assert::same(0, $data['scope']['files']); +}); + + +test('without the usual directories the root itself is the scope', function () use ($tabbed) { + $root = createProject('flat', ['A.php' => $tabbed]); + [$code] = runInit($root); + Assert::same(0, $code); + Assert::contains("paths: [.]\n", (string) file_get_contents("$root/dresscode.neon")); +}); + + +test('the sample is every k-th file of every directory, the same for the same tree, a large file among them', function () { + $files = []; + foreach (range(1, 400) as $i) { + $files[sprintf('src/f%03d.php', $i)] = " FileSample::MaxFiles, 'maxBytes' => FileSample::MaxBytes]; + $sample = FileSample::pick($root, $scope, ...$limits); + Assert::same(200, count($sample->files)); // every second of the 400 + Assert::same('src/f001.php', $sample->files[0]); + Assert::same('src/f003.php', $sample->files[1]); + Assert::contains('src/f007.php', $sample->files); + Assert::same($sample->files, FileSample::pick($root, $scope, ...$limits)->files); + Assert::same(400, count(FileSample::pick($root, $scope)->files)); // a scope this small is measured whole + + // a small directory beside a large one is sampled whole, and the large one gives the rest + @mkdir("$root/lib"); // @ directory may already exist + $lib = array_map(fn(int $i) => "lib/l$i.php", range(1, 10)); + foreach ($lib as $file) { + file_put_contents("$root/$file", " [10, 10], 'src' => [50, 400]], $sample->directories); + + // the budget of bytes is the cost of the measure, and it holds whatever the count allows + $files = []; + foreach (range(1, 30) as $i) { + $files[sprintf('src/g%02d.php', $i)] = "files)); // 22 * 90 kB is the last that fits in 2 MB + Assert::same(11, count(FileSample::pick($root, array_keys($files), FileSample::MaxFiles, 1_000_000)->files)); + Assert::same(10, count(FileSample::pick($root, array_keys($files), maxFiles: 10, maxBytes: FileSample::MaxBytes)->files)); // every third of the 30 + + // where no file fits, the smallest is the sample, as none would be no evidence at all + file_put_contents("$root/src/g07.php", " [1, 30]]], [$sample->files, $sample->directories]); + + // more directories than files the sample may have leave some of them out, the sample never growing past its limit + $files = []; + foreach (range(1, 12) as $i) { + $files[sprintf('src/d%02d/a.php', $i)] = "files)); + Assert::same(['src/d01' => [0, 1], 'src/d02' => [0, 1]], array_filter($sample->directories, fn(array $counts) => $counts[0] === 0)); +}); + + +test('the probes run in-process with --jobs 1 and measure the same', function () use ($tabbed) { + $root = createProject('jobs', ['src/A.php' => $tabbed, 'src/B.php' => str_replace('A', 'B', $tabbed), 'composer.json' => '{}']); + $neon = fn(?int $jobs) => json_decode( + runInit($root, ['--use', 'perCs', '--dry-run', '--format', 'json'], jobs: $jobs)[1], + associative: true, + flags: JSON_THROW_ON_ERROR, + )['configuration']; + Assert::same($neon(null), $neon(1)); +}); + + +test('a file git does not track is not measured, and init says the first fix takes it too', function () use ($tabbed) { + $root = createProject('untracked', ['src/A.php' => $tabbed, 'composer.json' => '{}']); + exec('git -C ' . escapeshellarg($root) . ' init -q 2>&1 && git -C ' . escapeshellarg($root) . ' add -A 2>&1', $output, $exit); + if ($exit !== 0) { + Tester\Environment::skip('git does not run: ' . implode("\n", $output)); + } + + file_put_contents("$root/src/B.php", " [$b], 'src/a.php' => [$a, $c]]); Assert::same(3, $baseline->count()); $baseline->save($file); Assert::match(<<<'XX' @@ -67,11 +64,11 @@ test('of the refused risky fixes it holds only those changing what the code does risk: $risk, refused: true, ); - $baseline = Baseline::fromResults([new FileResult('src/a.php', '', '', [ + $baseline = Baseline::fromViolations(['src/a.php' => [ $refused('test/type', Risk::TypeUnknown), $refused('test/name', Risk::NameUncertain), $refused('test/behavior', Risk::BehaviorChanges), - ])]); + ]]); Assert::same(1, $baseline->count()); Assert::true($baseline->has('src/a.php', 'test/behavior')); }); @@ -81,7 +78,7 @@ test('a fingerprint of nothing but digits survives the round trip', function () // PHP turns such a key into an int, NEON writes an unquoted number and the loader refuses the file $digits = new Violation('test/a', 'M.', 1, null, Severity::Error, fingerprint: '9231335105126121'); $file = "$dir/digits.neon"; - Baseline::fromResults([new FileResult('src/a.php', '', '', [$digits])])->save($file); + Baseline::fromViolations(['src/a.php' => [$digits]])->save($file); Assert::contains("'9231335105126121'", (string) file_get_contents($file)); $baseline = Baseline::load($file); assert($baseline !== null); diff --git a/tests/DressCode/Engine/Helpers.phpt b/tests/DressCode/Engine/Helpers.phpt index c3c52beae7..adf4f6358c 100644 --- a/tests/DressCode/Engine/Helpers.phpt +++ b/tests/DressCode/Engine/Helpers.phpt @@ -113,3 +113,21 @@ test('writeFile leaves a read-only file alone', function () { Assert::same('old', file_get_contents("$dir/a.php")); }); + + +test('replaceFile writes the file whole, its directory created, and leaves no temporary file', function () { + $dir = createTempDir('write'); + Helpers::replaceFile("$dir/state/a.json", 'one'); + Helpers::replaceFile("$dir/state/a.json", 'two'); + Assert::same('two', file_get_contents("$dir/state/a.json")); + Assert::same(['a.json'], array_values(array_diff(scandir("$dir/state"), ['.', '..']))); +}); + + +test('createFile never writes over a file that exists', function () { + $dir = createTempDir('write'); + Assert::true(Helpers::createFile("$dir/a.neon", 'one')); + Assert::false(Helpers::createFile("$dir/a.neon", 'two')); + Assert::same('one', file_get_contents("$dir/a.neon")); + Assert::same(['a.neon'], array_values(array_diff(scandir($dir), ['.', '..']))); +}); diff --git a/tests/DressCode/Engine/PassLoop.phpt b/tests/DressCode/Engine/PassLoop.phpt index a07d06e269..cefd3c108e 100644 --- a/tests/DressCode/Engine/PassLoop.phpt +++ b/tests/DressCode/Engine/PassLoop.phpt @@ -772,7 +772,7 @@ test('suppression stops the fix', function () { test('a rule may fix a violation the baseline holds, and the contract holds', function () { $code = "violations[0]])]); + $baseline = DressCode\Engine\Baseline::fromViolations(['test.php' => [$result->violations[0]]]); [$file, $result] = run($code, [new RenameA], baseline: $baseline); Assert::same("violations, fn($v) => $v->decision === 'mistakes.globalStatement', )); - $baseline = Baseline::fromResults([new FileResult('a.php', $code, $code, $global)]); + $baseline = Baseline::fromViolations(['a.php' => $global]); $result = processWithBaseline($rules(), $code, $baseline); Assert::same("output); @@ -203,7 +203,7 @@ test('a violation the baseline holds is not recognized in a later round on a lin test('a violation the baseline holds is not recorded, and a rule that can fix it fixes it', function () { $rules = fn() => [RuleBuilder::createRule(DressCode\Rules\Literals\StringQuotesRule::class, ['literals.quotes' => 'single'])]; $code = " processWithBaseline($rules(), $code, null)->violations]); $result = processWithBaseline($rules(), $code, $baseline); Assert::same("output); diff --git a/tests/DressCode/Engine/ProcessRunner.phpt b/tests/DressCode/Engine/ProcessRunner.phpt new file mode 100644 index 0000000000..6b1a85ef2e --- /dev/null +++ b/tests/DressCode/Engine/ProcessRunner.phpt @@ -0,0 +1,46 @@ +NUL", $output); + return str_contains(implode("\n", $output), (string) $pid); + } + + exec("kill -0 $pid 2>/dev/null", result_code: $exit); + return $exit === 0; +} + + +test('a command reads its input, and writes both streams unless the errors are left out', function () { + $dir = createTempDir('process-runner'); + file_put_contents("$dir/echo.php", ' 0); + usleep(500_000); + Assert::false(isProcessRunning($pid)); +}); diff --git a/tests/DressCode/Engine/Profiler.phpt b/tests/DressCode/Engine/Profiler.phpt new file mode 100644 index 0000000000..e2b8722fbb --- /dev/null +++ b/tests/DressCode/Engine/Profiler.phpt @@ -0,0 +1,53 @@ +addPhase('parse', 10); + $worker->addRule('a/rule', 5, mutated: true); + $worker->addRule('a/rule', 7, mutated: false); + $worker->addClaim('a/gap', 3); + $worker->addFile(['path' => 'x.php', 'size' => 1, 'time' => 30]); + $records = $worker->takeRecords(); + + $worker->addPhase('parse', 20); + $worker->addFile(['path' => 'y.php', 'size' => 1, 'time' => 40]); + $second = $worker->takeRecords(); + Assert::same(['parse' => [20, 1]], $second['phases']); // taken records are forgotten + + $parent = new Profiler; + $parent->merge(json_decode(json_encode($records), associative: true)); // as it travels between processes + $parent->merge(json_decode(json_encode($second), associative: true)); + $profile = $parent->toArray(); + + Assert::same(Profiler::Schema, $profile['schema']); + Assert::same(2, $profile['files']); + Assert::same(['parse' => ['time' => 30, 'calls' => 2]], $profile['phases']); + Assert::same(['a/rule' => ['time' => 12, 'calls' => 2, 'mutating' => 1]], $profile['rules']); + Assert::same(['a/gap' => ['time' => 3, 'calls' => 1]], $profile['claims']); + Assert::same(['y.php', 'x.php'], array_column($profile['slowestFiles'], 'path')); +}); + + +test('the most expensive come first, the slowest files in detail and every file in short', function () { + $profiler = new Profiler; + $profiler->addRule('cheap', 1, mutated: false); + $profiler->addRule('dear', 100, mutated: false); + for ($i = 1; $i <= 120; $i++) { + $profiler->addFile(['path' => "$i.php", 'size' => $i, 'time' => $i]); + } + + $profile = $profiler->toArray(); + Assert::same(['dear', 'cheap'], array_keys($profile['rules'])); + Assert::same(120, $profile['files']); + Assert::count(50, $profile['slowestFiles']); + Assert::same('120.php', $profile['slowestFiles'][0]['path']); + Assert::same('71.php', $profile['slowestFiles'][49]['path']); + Assert::count(120, $profile['fileTimes']); // every file, for a sample to be drawn from + Assert::same(['1.php', '10.php', '100.php'], array_slice(array_keys($profile['fileTimes']), 0, 3)); +}); diff --git a/tests/DressCode/Engine/RunResult.phpt b/tests/DressCode/Engine/RunResult.phpt index 0c79e3b083..84206f409b 100644 --- a/tests/DressCode/Engine/RunResult.phpt +++ b/tests/DressCode/Engine/RunResult.phpt @@ -107,3 +107,17 @@ test('the derived violations are counted among those the run reports', function Assert::same(1, new RunResult($files, fix: true)->countDerived(Severity::Error)); Assert::same(0, new RunResult($files, fix: true)->countDerived(Severity::Warning)); }); + + +test('the violations no rule has a fix for are counted among those the run reports', function () { + $unfixable = new Violation('test/a', 'M.', 1, null, Severity::Error, fingerprint: 'a', fixable: false); + $files = [FileSummary::of(new FileResult( + 'a.php', + "countUnfixable()); + Assert::same(1, new RunResult($files, fix: true)->countUnfixable()); +}); diff --git a/tests/DressCode/Engine/Runner.phpt b/tests/DressCode/Engine/Runner.phpt index 28c08bd6dd..bad3fd6008 100644 --- a/tests/DressCode/Engine/Runner.phpt +++ b/tests/DressCode/Engine/Runner.phpt @@ -2,6 +2,7 @@ use DressCode\{Analyses, Config, ConfigurationException, FileResult, NodeRule, Reporter, RuleContext, RuleInfo, Stage}; use DressCode\Engine\{FileProcessors, FileSummary, PhpFileProcessor, ReportPolicy, RunInfo, Runner, RunResult}; +use DressCode\Reporters\CollectingReporter; use PhpSyntax\{Node, Token}; use PhpSyntax\Nodes\Expression\VariableNode; use Tester\Assert; @@ -155,6 +156,9 @@ final class RecordingReporter implements Reporter /** @var array */ public array $texts = []; + /** @var array */ + public array $results = []; + public function start(RunInfo $run): void { @@ -164,6 +168,7 @@ final class RecordingReporter implements Reporter public function reportFile(FileResult $result): void { + $this->results[$result->path] = $result; $this->texts[$result->path] = [$result->code, $result->output]; $this->events[] = "file $result->path " . json_encode($result->changed) . ' ' . json_encode($result->written) . ($result->failure === null ? '' : " failure: $result->failure"); @@ -220,10 +225,10 @@ function engine( test('a dot path is the root itself and never matches the dot-prefixed exclusion', function () use ($root) { $runner = engine($root, excludePaths: ['vendor', 'fixtures*', '.*']); - Assert::same('', $runner->relativize('.')); - Assert::same('src/a.php', $runner->relativize('./src/./a.php')); - Assert::contains('src/a.php', $runner->findFiles(['.'])); - Assert::contains('src/a.php', $runner->findFiles(['./src'])); + Assert::same('', $runner->files->relativize('.')); + Assert::same('src/a.php', $runner->files->relativize('./src/./a.php')); + Assert::contains('src/a.php', $runner->files->findFiles(['.'])); + Assert::contains('src/a.php', $runner->files->findFiles(['./src'])); }); @@ -231,51 +236,51 @@ test('files are found under the paths, sorted, relative, with slashes, without t $runner = engine($root); Assert::same( ['src/a.php', 'src/b.php', 'src/broken.php', 'src/skipped.php', 'src/sub/d.php'], - $runner->findFiles([str_replace('/', '\\', $root) . '\src']), + $runner->files->findFiles([str_replace('/', '\\', $root) . '\src']), ); - Assert::same(['src/a.php', 'src/b.php', 'src/broken.php', 'src/skipped.php', 'src/sub/d.php'], $runner->findFiles(['src'])); - Assert::same(['src/sub/d.php', 'vendor/f.php'], $runner->findFiles(['./src/sub', 'vendor/f.php'])); - Assert::same(['src/sub/d.php'], $runner->findFiles(['./src/sub', 'vendor/f.php', 'src/fixtures/e.php'], skipExcluded: true)); + Assert::same(['src/a.php', 'src/b.php', 'src/broken.php', 'src/skipped.php', 'src/sub/d.php'], $runner->files->findFiles(['src'])); + Assert::same(['src/sub/d.php', 'vendor/f.php'], $runner->files->findFiles(['./src/sub', 'vendor/f.php'])); + Assert::same(['src/sub/d.php'], $runner->files->findFiles(['./src/sub', 'vendor/f.php', 'src/fixtures/e.php'], skipExcluded: true)); Assert::same( ['src/c.phtml', 'src/fixtures/e.php'], - engine($root, excludePaths: [], fileExtensions: ['php', 'phtml'])->findFiles(['src/c.phtml', 'src/fixtures']), + engine($root, excludePaths: [], fileExtensions: ['php', 'phtml'])->files->findFiles(['src/c.phtml', 'src/fixtures']), ); - Assert::exception(fn() => $runner->findFiles(['missing']), ConfigurationException::class, 'Path `missing` does not exist.'); + Assert::exception(fn() => $runner->files->findFiles(['missing']), ConfigurationException::class, 'Path `missing` does not exist.'); $outside = str_replace('\\', '/', (string) realpath(__DIR__ . '/../Config/fixtures/project/src')); - Assert::same(["$outside/sub/file.php"], engine($root, excludePaths: [])->findFiles([$outside])); - Assert::same(["$outside/sub/file.php"], $runner->findFiles(["$outside/sub/file.php"], skipExcluded: true)); // fixtures* matches nothing outside the root + Assert::same(["$outside/sub/file.php"], engine($root, excludePaths: [])->files->findFiles([$outside])); + Assert::same(["$outside/sub/file.php"], $runner->files->findFiles(["$outside/sub/file.php"], skipExcluded: true)); // fixtures* matches nothing outside the root // with the configured paths, a named file outside them is left out as well Assert::same( ['src/sub/d.php'], - $runner->findFiles( + $runner->files->findFiles( ['src/sub/d.php', 'src/a.php', 'vendor/f.php', "$outside/sub/file.php"], skipExcluded: true, configured: ['./src/sub'], ), ); - Assert::same(['src/a.php'], $runner->findFiles(['src/a.php'], skipExcluded: true, configured: ['.'])); + Assert::same(['src/a.php'], $runner->files->findFiles(['src/a.php'], skipExcluded: true, configured: ['.'])); }); test('a directory named that holds configured paths is narrowed to them, anything else is taken as named', function () use ($root) { $runner = engine($root); $configured = ['src/sub', 'vendor/f.php']; - Assert::same(['src/sub', 'vendor/f.php'], $runner->narrowPaths(['.'], $configured)); - Assert::same(['src/sub'], $runner->narrowPaths(['src', "$root/src/"], $configured)); + Assert::same(['src/sub', 'vendor/f.php'], $runner->files->narrowPaths(['.'], $configured)); + Assert::same(['src/sub'], $runner->files->narrowPaths(['src', "$root/src/"], $configured)); Assert::same( ['src/sub/d.php', 'src/su', 'src/fixtures'], - $runner->narrowPaths(['src/sub/d.php', 'src/su', 'src/fixtures'], $configured), + $runner->files->narrowPaths(['src/sub/d.php', 'src/su', 'src/fixtures'], $configured), ); - Assert::same(['vendor/f.php'], $runner->narrowPaths(['vendor/f.php'], $configured)); - Assert::same(['.'], $runner->narrowPaths(['.'], [])); + Assert::same(['vendor/f.php'], $runner->files->narrowPaths(['vendor/f.php'], $configured)); + Assert::same(['.'], $runner->files->narrowPaths(['.'], [])); }); test('check reports and writes nothing', function () use ($root) { $reporter = new RecordingReporter; $runner = engine($root, skipWhen: fn(string $content) => str_contains($content, '// skip me')); - $result = $runner->run($runner->findFiles(['src']), fix: false, reporter: $reporter); + $result = $runner->run($runner->files->findFiles(['src']), fix: false, reporter: $reporter); Assert::same([ 'start 5 false', 'file src/a.php true false', @@ -424,6 +429,37 @@ test('a reporter sees the texts of a file, what the run keeps of it does not', f }); +test('a reporter sees the violations of a file, the run only counts them, a large tree having millions', function () use ($root) { + file_put_contents("$root/src/a.php", "> */ + public array $seen = []; + + + public function start(RunInfo $run): void + { + } + + + public function reportFile(FileResult $result): void + { + array_push($this->seen, ...array_map(WeakReference::create(...), $result->violations)); + } + + + public function finish(RunResult $result): void + { + } + }; + + $result = engine($root)->run(['src/a.php', 'src/b.php'], fix: false, reporter: $reporter); + Assert::same(2, $result->countViolations()); + Assert::count(2, $reporter->seen); + Assert::null($reporter->seen[0]->get()); // the processor may still hold those of the last file +}); + + test('processCode processes a text for the given path and writes nothing', function () use ($root) { $runner = engine($root); Assert::true($runner->processCode("$root/src/x.php", "changed); @@ -440,15 +476,15 @@ test('the baseline keeps what it holds from being reported, and the run counts i Config::DefaultPhpVersion, policy: new ReportPolicy(baseline: $baseline), ); - $baseline = DressCode\Engine\Baseline::fromResults( - new Runner($processor(null), $root)->run(['src/a.php'], false, new RecordingReporter)->files, - ); + $generated = new CollectingReporter; + new Runner($processor(null), $root)->run(['src/a.php'], false, $generated); + $baseline = DressCode\Engine\Baseline::fromViolations($generated->violations); Assert::same(1, $baseline->count()); $run = new Runner($processor($baseline), $root, baseline: $baseline)->run(['src/a.php', 'src/b.php'], false, new RecordingReporter); Assert::same(1, $run->countViolations()); // the one of src/b.php, which the baseline does not know Assert::same(1, $run->baselined); - Assert::same([], $run->files[0]->violations); + Assert::same(0, $run->files[0]->violations->count()); Assert::same([], $run->warnings); }); @@ -462,23 +498,26 @@ test('a violation the baseline holds is fixed by a rule that can fix it, and its Config::DefaultPhpVersion, policy: new ReportPolicy(baseline: $baseline), ); - $generated = new Runner($processor(null), $root)->run(['src/a.php'], false, new RecordingReporter)->files; + $generated = new CollectingReporter; + new Runner($processor(null), $root)->run(['src/a.php'], false, $generated); // every run loads the baseline anew, and an instance remembers the entries its runs matched $runner = function () use ($processor, $root, $generated): Runner { - $baseline = DressCode\Engine\Baseline::fromResults($generated); + $baseline = DressCode\Engine\Baseline::fromViolations($generated->violations); return new Runner($processor($baseline), $root, baseline: $baseline); }; // check reports only what the baseline does not know, and fix reports the same - $fingerprints = fn(RunResult $run) => array_map( - fn(FileSummary $file) => array_map(fn($violation) => $violation->fingerprint, $file->violations), - $run->files, + $fingerprints = fn(CollectingReporter $reporter) => array_map( + fn(array $violations) => array_column($violations, 'fingerprint'), + $reporter->violations, ); - $check = $runner()->run(['src/a.php', 'src/b.php'], false, new RecordingReporter); + $checked = new CollectingReporter; + $check = $runner()->run(['src/a.php', 'src/b.php'], false, $checked); Assert::same(1, $check->countViolations()); Assert::same(1, $check->baselined); - $fix = $runner()->run(['src/a.php', 'src/b.php'], true, new RecordingReporter); - Assert::same($fingerprints($check), $fingerprints($fix)); + $fixed = new CollectingReporter; + $fix = $runner()->run(['src/a.php', 'src/b.php'], true, $fixed); + Assert::same($fingerprints($checked), $fingerprints($fixed)); Assert::same(0, $fix->getExitCode()); Assert::same([], $fix->warnings); // the first round matched the entry @@ -597,9 +636,10 @@ test('a cached file tells the baseline what it matched, so a second run counts t Config::DefaultPhpVersion, policy: new ReportPolicy(baseline: $baseline), ); - $generated = new Runner($processor(null), $root)->run(['src/a.php'], false, new RecordingReporter); + $generated = new CollectingReporter; + new Runner($processor(null), $root)->run(['src/a.php'], false, $generated); $run = function () use ($root, $file, $processor, $generated): RunResult { - $baseline = DressCode\Engine\Baseline::fromResults($generated->files); + $baseline = DressCode\Engine\Baseline::fromViolations($generated->violations); return new Runner($processor($baseline), $root, baseline: $baseline, cache: DressCode\Engine\ResultCache::load($file, 'cfg')) ->run(['src/a.php'], false, new RecordingReporter); }; @@ -621,9 +661,10 @@ test('an entry of the baseline is stale only when its file was processed and its Config::DefaultPhpVersion, policy: new ReportPolicy(baseline: $baseline), ); - $generated = new Runner($processor(true), $root)->run(['src/a.php', 'src/b.php'], false, new RecordingReporter); + $generated = new CollectingReporter; + new Runner($processor(true), $root)->run(['src/a.php', 'src/b.php'], false, $generated); $warnings = function (bool $rename, bool $narrowed, bool $both) use ($root, $processor, $generated): array { - $baseline = DressCode\Engine\Baseline::fromResults($generated->files); + $baseline = DressCode\Engine\Baseline::fromViolations($generated->violations); return new Runner($processor($rename, $baseline), $root, baseline: $baseline, narrowed: $narrowed) ->run($both ? ['src/a.php', 'src/b.php'] : ['src/a.php'], false, new RecordingReporter) ->warnings; @@ -651,10 +692,12 @@ test('a fix is judged by the text it leaves, so a problem reported beside a fix cache: DressCode\Engine\ResultCache::load($file, 'cfg'), ); - $run = $runner()->run(['src/a.php'], true, new RecordingReporter); + $reporter = new RecordingReporter; + $run = $runner()->run(['src/a.php'], true, $reporter); Assert::same(" $v->message, $run->files[0]->violations)); - Assert::same(['A problem no fix removes.'], array_map(fn($v) => $v->message, $run->files[0]->remaining)); + $result = $reporter->results['src/a.php']; + Assert::same(['A problem no fix removes.', 'Rename $a.'], array_column($result->violations, 'message')); + Assert::same(['A problem no fix removes.'], array_column($result->remaining, 'message')); Assert::same(1, $run->getExitCode()); Assert::false($runner()->run(['src/a.php'], false, new RecordingReporter)->files[0]->cached); // the fixed text was not clean }); diff --git a/tests/DressCode/Engine/Suppression.phpt b/tests/DressCode/Engine/Suppression.phpt index c593ae0cc5..66b26d8054 100644 --- a/tests/DressCode/Engine/Suppression.phpt +++ b/tests/DressCode/Engine/Suppression.phpt @@ -7,7 +7,8 @@ use Tester\Assert; require __DIR__ . '/../../bootstrap.php'; -$resolve = fn(string $name) => str_starts_with($name, 'dresscode/') ? [$name] : []; +$foreign = ['Generic.Files.LineLength' => ['dresscode/lineLength']]; +$resolve = fn(string $name) => $foreign[$name] ?? (str_starts_with($name, 'dresscode/') ? [$name] : []); /** @param Closure(string): list $resolve */ @@ -191,15 +192,98 @@ test('disable and enable, also without a matching enable', function () use ($res }); -test('ignoreFile silences the whole file', function () use ($resolve) { +test('ignoreFile and phpcs forms with alias translation', function () use ($resolve) { $s = suppression("isSilenced('dresscode/x', 3)); + + $s = suppression(<<<'XX' + isSilenced('dresscode/lineLength', 2)); + Assert::true($s->isSilenced('dresscode/lineLength', 4)); + Assert::false($s->isSilenced('dresscode/lineLength', 5)); + Assert::true($s->isSilenced('dresscode/lineLength', 10)); + Assert::false($s->isSilenced('dresscode/lineLength', 12)); + + $s = suppression("isSilenced('dresscode/x', 3)); }); test('the hyphenated ignore-file is no directive', function () use ($resolve) { - $s = suppression("isSilenced('dresscode/x', 3)); + foreach (['dresscode', 'phpcs'] as $prefix) { + $s = suppression("isSilenced('dresscode/x', 3)); + } +}); + + +test('@phpcsSuppress with our names, several in one tag', function () use ($resolve) { + $s = suppression(<<<'XX' + isSilenced('dresscode/a', 7)); + Assert::true($s->isSilenced('dresscode/b', 7)); + Assert::true($s->isSilenced('dresscode/c', 7)); + Assert::false($s->isSilenced('dresscode/d', 7)); +}); + + +test('@phpcsSuppress on the first item of a list covers that item, not the list', function () use ($resolve) { + $s = suppression(<<<'XX' + isSilenced('dresscode/a', 4)); + Assert::false($s->isSilenced('dresscode/a', 5)); + Assert::true($s->isSilenced('dresscode/b', 9)); + Assert::false($s->isSilenced('dresscode/b', 10)); + Assert::true($s->isSilenced('dresscode/c', 14)); + Assert::false($s->isSilenced('dresscode/c', 12)); +}); + + +test('@phpcsSuppress after a parameter covers the parameter', function () use ($resolve) { + $s = suppression(<<<'XX' + isSilenced('dresscode/a', 3)); + Assert::false($s->isSilenced('dresscode/a', 4)); }); @@ -232,3 +316,19 @@ test('a comment the configuration names silences its rules on its line', functio Assert::true($s->isSilenced('dresscode/noLooseComparisons', 11)); Assert::false($s->isSilenced('dresscode/noLooseComparisons', 12)); }); + + +test('a directive is read with its kind, names and reason, and a comment without one none', function () { + Assert::same( + [ + 'kind' => 'ignore', + 'names' => 'upgrading.php', + 'reason' => 'the locale', + 'text' => 'dresscode:ignore upgrading.php -- the locale', + 'offset' => 3, + ], + Suppression::findDirective('// dresscode:ignore upgrading.php -- the locale'), + ); + Assert::same(['disable', '', ''], array_slice(array_values(Suppression::findDirective('/* phpcs:disable */') ?? []), 0, 3)); + Assert::null(Suppression::findDirective('// the ignore of dresscode')); +}); diff --git a/tests/DressCode/Engine/WorkerCodec.phpt b/tests/DressCode/Engine/WorkerCodec.phpt index f050bd3dde..ff0f7e2a06 100644 --- a/tests/DressCode/Engine/WorkerCodec.phpt +++ b/tests/DressCode/Engine/WorkerCodec.phpt @@ -1,5 +1,6 @@ code); }; -test('a result survives the way from a worker to the parent and back', function () use ($roundTrip) { +test('a result survives the way from a worker to the parent and back, with what the worker measured', function () use ($roundTrip) { $violation = new Violation( 'test/a', 'A.', @@ -29,6 +30,7 @@ test('a result survives the way from a worker to the parent and back', function offset: 6, length: 2, originalLines: [2, 3], + source: new Layer(LayerKind::Package, 'php.neon', 'php', 'upgrading.php.deprecatedApi', 'php', '8.5', '8.0'), ); $unfixable = new Violation('test/b', 'B.', 3, null, Severity::Error, fingerprint: '5678', fixable: false); $result = new FileResult( @@ -46,15 +48,15 @@ test('a result survives the way from a worker to the parent and back', function ], ); $result->markWritten(); - Assert::equal($result, $roundTrip($result)); + Assert::equal([$result, ['files' => [1]]], $roundTrip($result, ['files' => [1]])); }); test('an output that is not valid UTF-8 survives the way byte for byte', function () { $code = "output, $decoded->output); Assert::same($code, $decoded->code); }); @@ -62,15 +64,15 @@ test('an output that is not valid UTF-8 survives the way byte for byte', functio test('a path that is not valid UTF-8 survives the way byte for byte', function () use ($roundTrip) { $result = new FileResult("\xE9.php", "code); + $json = json_encode(WorkerCodec::encode($result, null), JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE); + [$decoded] = WorkerCodec::decode(json_decode($json, associative: true), $result->code); Assert::same("bad \u{FFFD} byte.", $decoded->violations[0]->message); Assert::same("bad \u{FFFD} byte.", $decoded->remaining[0]->message); }); @@ -78,8 +80,8 @@ test('a message that is not valid UTF-8 gets through the transport with the bad test('an unchanged output, a syntax error and a result from the cache', function () use ($roundTrip) { $clean = new FileResult('a.php', " false, 'removedImports' => [], 'mutatedRules' => [], + 'profile' => null, 'dependencies' => ['stubs/' . (mb_check_encoding($name = (string) base64_decode($path), 'UTF-8') ? $name : 'other') . '.stub'], 'runWarnings' => ['The types go without the configuration.'], ], JSON_THROW_ON_ERROR) . "\n"; diff --git a/tests/DressCode/Interop/FormatterConfig.phpt b/tests/DressCode/Interop/FormatterConfig.phpt new file mode 100644 index 0000000000..36cb17dcc7 --- /dev/null +++ b/tests/DressCode/Interop/FormatterConfig.phpt @@ -0,0 +1,211 @@ +../src'); + Assert::same(['src'], FormatterConfig::fromFile($root, 'conf/phpcs.xml')->paths); + + // a path that does not exist is refused, as the formatter refuses it, rather than leaving the scope to the autoload + file_put_contents( + "$root/conf/phpcs.xml", + '../src../missing', + ); + Assert::exception( + fn() => FormatterConfig::fromFile($root, 'conf/phpcs.xml'), + DressCode\ConfigurationException::class, + '`%a%conf/phpcs.xml` names `../missing`, which does not exist.', + ); + + // the root takes all, and an absolute path is where it says + file_put_contents("$root/phpcs.xml", 'src.'); + Assert::same(['.'], FormatterConfig::fromFile($root, 'phpcs.xml')->paths); + file_put_contents("$root/phpcs.xml", '' . $root . '/src'); + Assert::same(['src'], FormatterConfig::fromFile($root, 'phpcs.xml')->paths); + + file_put_contents( + "$root/.php-cs-fixer.dist.php", + "setFinder(PhpCsFixer\\Finder::create()->in(__DIR__ . '/lib')->in(__DIR__ . '/test'));\n", + ); + Assert::same(['lib', 'test'], FormatterConfig::fromFile($root, '.php-cs-fixer.dist.php')->paths); + + // what the finder leaves out by a directory, a path and a name is what the scope leaves out + file_put_contents( + "$root/.php-cs-fixer.dist.php", + "setFinder(PhpCsFixer\\Finder::create()->in(__DIR__ . '/lib')" + . "->exclude('Cache')->notPath('#/Fixtures/#')->notName('*.phpt')->notName('/[.]draft[.]php$/'));\n", + ); + $config = FormatterConfig::fromFile($root, '.php-cs-fixer.dist.php'); + Assert::true($config->excludes('lib/Cache/a.php')); + Assert::true($config->excludes('lib/Http/Fixtures/a.php')); + Assert::true($config->excludes('lib/Http/a.phpt')); + Assert::true($config->excludes('lib/Http/a.draft.php')); + Assert::false($config->excludes('lib/Http/a.php')); + Assert::same([], $config->unread); + + // a pattern of a name is matched against the name, a finder narrowed by names says it, and appended files alone are the scope + file_put_contents( + "$root/.php-cs-fixer.dist.php", + "setFinder(PhpCsFixer\\Finder::create()->in(__DIR__ . '/lib')" + . "->notName('/^Legacy/')->name('Wanted.php'));\n", + ); + $config = FormatterConfig::fromFile($root, '.php-cs-fixer.dist.php'); + Assert::true($config->excludes('lib/Legacy.php')); + Assert::false($config->excludes('lib/OldLegacy.php')); + Assert::same(['name()'], $config->unread); + + file_put_contents("$root/test/extra.php", "setFinder(PhpCsFixer\\Finder::create()->append([__DIR__ . '/test/extra.php']));\n", + ); + Assert::same(['test/extra.php'], FormatterConfig::fromFile($root, '.php-cs-fixer.dist.php')->paths); +}); + + +test('a ruleset gives its extensions and the paths it ignores, and says what else of it is not carried over', function () { + $root = createTempDir('foreign-ruleset-args'); + file_put_contents("$root/phpcs.xml", <<<'XX' + + + + + + + + + + + + + + + XX); + $config = FormatterConfig::fromFile($root, 'phpcs.xml'); + Assert::same(['php', 'inc'], $config->extensions); + Assert::true($config->excludes('src/cache/a.php')); + Assert::same( + ['', '', '', ''], + $config->unread, + ); + Assert::contains( + '`` of `phpcs.xml` is not carried over; what the configuration checks may differ from what PHP_CodeSniffer checked.', + $config->translate()->warnings, + ); +}); + + +test('without evidence the configurations go in the order of the formatters, a local one before the one distributed', function () { + Assert::same([], FormatterSearch::find(createTempDir('foreign-none'))); + $root = createTempDir('foreign-find'); + file_put_contents("$root/pint.json", '{"preset": "per"}'); + file_put_contents("$root/.php-cs-fixer.dist.php", " $candidate->format(), FormatterSearch::find($root)), + ); + Assert::equal( + new FormatterCandidate(Formatter::PhpCsFixer, '.php-cs-fixer.dist.php', null, Evidence::File), + FormatterSearch::find($root)[0], + ); + + // the local configuration is the one PHP CS Fixer reads + file_put_contents("$root/.php-cs-fixer.php", "file); + Assert::same( + [Formatter::Pint, 'perCs'], + [FormatterConfig::fromFile($root, 'pint.json')->formatter, FormatterConfig::fromFile($root, 'pint.json')->standard], + ); + Assert::true(Formatter::PhpCsFixer->runsCode()); + Assert::false(Formatter::PhpCodeSniffer->runsCode()); +}); + + +test('the formatter a script runs is chosen over a file nothing runs, a package it requires over a file alone', function () { + $root = createTempDir('foreign-evidence'); + @mkdir("$root/conf"); // @ directory may already exist + file_put_contents("$root/phpcs.xml", ''); + file_put_contents("$root/pint.json", '{}'); + file_put_contents("$root/conf/fixer.php", " file_put_contents( + "$root/composer.json", + json_encode(['scripts' => $scripts, 'require-dev' => $require]), + ); + $find = fn() => array_map(fn(FormatterCandidate $candidate) => $candidate->format(), FormatterSearch::find($root)); + + $scripts(['fix' => 'vendor/bin/pint --test']); + Assert::same(['`pint.json` of Laravel Pint, run by `composer fix`'], $find()); + + // a script calling another, the words of the shell with their quotes, and the file the option names + $scripts(['cs' => '@lint', 'lint' => ['@php -d memory_limit=-1 "vendor/bin/php-cs-fixer" fix --config "conf/fixer.php"']]); + Assert::same(['`conf/fixer.php` of PHP CS Fixer, run by `composer cs`'], $find()); + + // two formatters a script runs are both evidenced + $scripts(['cs' => 'vendor/bin/phpcs --standard=phpcs.xml && vendor/bin/pint']); + Assert::same(['`phpcs.xml` of PHP_CodeSniffer, run by `composer cs`', '`pint.json` of Laravel Pint, run by `composer cs`'], $find()); + + // a formatter is run by the program of a command, never by a word a command only passes on + $scripts([ + 'message' => 'echo pint', + 'lint' => 'XDEBUG_MODE=off php -d memory_limit=-1 vendor/bin/phpcs', + 'fix' => '@composer exec pint', + ]); + Assert::same(['`phpcs.xml` of PHP_CodeSniffer, run by `composer lint`', '`pint.json` of Laravel Pint, run by `composer fix`'], $find()); + + $scripts([], ['laravel/pint' => '^1.0']); + Assert::same([Evidence::Package], array_map(fn(FormatterCandidate $candidate) => $candidate->evidence, FormatterSearch::find($root))); +}); + + +test('a preset Laravel Pint does not have is refused, and what of its configuration is not carried over is said', function () { + $root = createTempDir('foreign-pint'); + file_put_contents("$root/pint.json", '{"preset": "typo"}'); + Assert::exception( + fn() => FormatterConfig::fromFile($root, 'pint.json')->translate(), + DressCode\ConfigurationException::class, + 'File `%a%pint.json` names the preset `typo`, which Laravel Pint does not have; it has `laravel`, %a%.', + ); + + file_put_contents("$root/pint.json", '{"preset": "empty", "notPath": ["legacy.php"]}'); + Assert::contains( + '`notPath` of `pint.json` is not carried over; what the configuration checks may differ from what Laravel Pint checked.', + FormatterConfig::fromFile($root, 'pint.json')->translate()->warnings, + ); + + // a file that is no JSON names no standard, which is not the default preset of a file naming none + file_put_contents("$root/pint.json", '{"preset": '); + $config = FormatterConfig::fromFile($root, 'pint.json'); + Assert::same([null, ['pint.json, which is no JSON']], [$config->standard, $config->unread]); + Assert::null(DressCode\Measuring\ProjectFacts::detect($root, $config)->findPintStandard()); + + // the formatter chosen decides, a `pint.json` beside another one saying nothing + file_put_contents("$root/pint.json", '{"preset": "psr12"}'); + file_put_contents("$root/phpcs.xml", ''); + Assert::null(DressCode\Measuring\ProjectFacts::detect($root, FormatterConfig::fromFile($root, 'phpcs.xml'))->findPintStandard()); + Assert::same( + 'psr12', + DressCode\Measuring\ProjectFacts::detect($root, FormatterConfig::fromFile($root, 'pint.json'))->findPintStandard(), + ); +}); + + +test('a set of PHP CS Fixer names the standard, @PhpCsFixer the nearest one, symfony', function () { + $root = createTempDir('foreign-sets'); + $standard = function (string $set) use ($root): ?string { + file_put_contents("$root/.php-cs-fixer.php", "setRules(['$set' => true]);\n"); + return FormatterConfig::fromFile($root, '.php-cs-fixer.php')->standard; + }; + Assert::same( + ['perCs', 'psr12', 'symfony', 'symfony', null], + array_map($standard, ['@PER-CS2.0', '@PSR12', '@Symfony', '@PhpCsFixer', '@Foo']), + ); +}); diff --git a/tests/DressCode/Interop/PhpCsFixerSource.phpt b/tests/DressCode/Interop/PhpCsFixerSource.phpt new file mode 100644 index 0000000000..18f0787891 --- /dev/null +++ b/tests/DressCode/Interop/PhpCsFixerSource.phpt @@ -0,0 +1,62 @@ +in(__DIR__ . '/commented') is no call + $rules = ['@PSR12' => true, '@Symfony:risky' => false, 'strict_param' => true]; + $finder = PhpCsFixer\Finder::create() + ->in(__DIR__ . '/src') + ->exclude(['Generated', 'Cache']) + ->notPath('#/Fixtures/#') + ->notName('*.phpt') + ->append([__FILE__]); + $finder->in('tests'); + $config->getFinder()->in(__DIR__ . '/lib'); + (new Finder)->in($dirs)->filter(fn() => true); + $config->setRules(getRules()); + return (new PhpCsFixer\Config)->setRules($rules)->setFinder($finder); + XX, '/project/.php-cs-fixer.dist.php'); + + Assert::same(['/project/src', 'tests', '/project/lib'], $source->in); + Assert::same(['Generated', 'Cache'], $source->exclude); + Assert::same(['#/Fixtures/#'], $source->notPath); + Assert::same(['*.phpt'], $source->notName); + Assert::same(['/project/.php-cs-fixer.dist.php'], $source->append); + Assert::same(['@PSR12'], $source->sets); + Assert::same(['2 finders, whose scopes are taken together', 'filter()', 'in($dirs)'], $source->unread); +}); + + +test('what the reading of the finder cannot be sure of is told: a condition, a method it does not know, another class', function () { + $source = PhpCsFixerSource::fromCode(<<<'XX' + in(__DIR__)->ignoreDotFiles(false)->sortByName(); + if (getenv('CI')) { + $finder->exclude('slow'); + } + $finder = Acme\Finder::create(); + return (new PhpCsFixer\Config)->setFinder($finder); + XX, '/project/.php-cs-fixer.php'); + + Assert::same(['/project'], $source->in); + Assert::same([], $source->exclude); + Assert::same([ + '$finder, which a finder was assigned to and is assigned again', + '`Acme\Finder`, a class named Finder of another vendor', + 'ignoreDotFiles()', + 'exclude(\'slow\'), which a condition decides', + ], $source->unread); +}); + + +test('code that does not parse is read as nothing', function () { + Assert::same(['the configuration, which does not parse'], PhpCsFixerSource::fromCode('', '/x.php')->unread); +}); diff --git a/tests/DressCode/Interop/Translator.phpt b/tests/DressCode/Interop/Translator.phpt new file mode 100644 index 0000000000..7dc46b5cc7 --- /dev/null +++ b/tests/DressCode/Interop/Translator.phpt @@ -0,0 +1,1454 @@ +|Closure(array, Translation): mixed> */ +function allTranslations(): array +{ + return PhpCsFixer::getTranslations() + PhpCodeSniffer::getTranslations(); +} + + +/** Resolves the configuration the translation prints, which throws on a decision or a value the catalogue does not accept. */ +function resolveTranslation(Translation $translation): DressCode\Config\ResolvedConfig +{ + $file = createTempDir('translator') . '/dresscode.php'; + file_put_contents($file, $translation->toDraft()->toPhp()); + return new ConfigResolver(new PluginRegistry)->resolve(Loader::loadFile($file), DressCode\Config::DefaultPhpVersion); +} + + +/** + * @param array $places place => value + * @return array path => value + */ +function commaPlaces(array $places): array +{ + $paths = []; + foreach ($places as $place => $value) { + $paths["multiline.trailingComma.$place"] = $value; + } + + return $paths; +} + + +test('a translation names a decision that exists and values it accepts', function () { + $catalogue = (new PluginRegistry)->getCatalogue(); + $translator = new Translator; + foreach (array_keys(allTranslations()) as $foreign) { + $paths = $translator->findPaths($foreign); + Assert::true( + $paths !== [] || $translator->translate([$foreign => true])->warnings !== [], + "$foreign translates to nothing and says nothing", + ); + foreach ($paths as $path) { + Assert::notNull($catalogue->find($path), "$foreign names the unknown decision $path"); + } + + Assert::noError(fn() => resolveTranslation($translator->translate([$foreign => true]))); + } +}); + + +test('the two name spaces do not overlap', function () { + Assert::same([], array_intersect_key(PhpCsFixer::getTranslations(), PhpCodeSniffer::getTranslations())); + Assert::same([], array_intersect_key(PhpCsFixer::getSets(), PhpCodeSniffer::getSets())); + foreach (array_keys(PhpCsFixer::getTranslations()) as $fixer) { + Assert::false(str_contains($fixer, '.'), "$fixer is not a fixer name"); + } + + foreach (array_keys(PhpCodeSniffer::getTranslations()) as $sniff) { + Assert::true((bool) preg_match('~^\w+(\.\w+)+$~D', $sniff), "$sniff is not a sniff code"); + } +}); + + +test('a set becomes a preset, an unknown rule a warning', function () { + $translation = (new Translator)->translate([ + '@PSR12' => true, 'PSR12' => true, '@Symfony' => true, '@PhpCsFixer' => true, 'no_such_fixer' => true, + 'Squiz' => true, 'Generic.Files.LineLength.TooLong' => true, 'Acme.Cat.Name.Code' => true, + ]); + Assert::same(['dresscode/psr12', 'dresscode/symfony'], $translation->presets); + Assert::same([], $translation->decisions); + Assert::same([ + 'The rule set `@PhpCsFixer` has no DressCode preset; start from the preset `symfony`, the nearest one.', + 'No DressCode rule covers `no_such_fixer`.', + 'The standard `Squiz` has no DressCode preset; start from the preset `perCs` or `psr12`.', + '`Generic.Files.LineLength.TooLong` is one message of `Generic.Files.LineLength`, which DressCode cannot enable alone.', + 'No DressCode rule covers `Acme.Cat.Name.Code`.', + ], $translation->warnings); +}); + + +test('a set moving the code to a version of PHP stands for its rules, which a later rule overrides', function () { + $translation = (new Translator)->translate(['@PHP84Migration' => true, 'array_syntax' => false]); + Assert::same([], $translation->presets); + Assert::same('keep', $translation->decisions['literals.arraySyntax.array']); + Assert::same('short', $translation->decisions['literals.arraySyntax.list']); + Assert::same('forbidden', $translation->decisions['classes.instantiationWrapping']); + Assert::same('forbidden', $translation->decisions['upgrading.php.implicitNullable']); + // what PHP 8.0 no longer parses has nothing to translate + Assert::false(array_any($translation->warnings, fn(string $warning) => str_contains($warning, 'normalize_index_brace'))); + + // a set turned off turns its rules off + Assert::same('keep', (new Translator)->translate(['@PHP8x4Migration' => false])->decisions['classes.instantiationWrapping']); +}); + + +test('the tags of final_internal_class are annotations, and the translation says they may be attributes', function () { + $translation = (new Translator)->translate(['final_internal_class' => ['include' => ['internal', '@Custom'], 'exclude' => ['Entity']]]); + Assert::same(['@internal', '@Custom'], $translation->decisions['classes.internalClass.markers']); + Assert::same(['@Entity'], $translation->decisions['classes.internalClass.except']); + Assert::contains('reads `internal`, `@Custom`, `Entity` as attributes too', implode("\n", $translation->warnings)); + + // an empty include makes every class final, which the markers cannot say + $translation = (new Translator)->translate( + ['final_internal_class' => ['include' => [], 'consider_absent_docblock_as_internal_class' => true]], + ); + Assert::false(isset($translation->decisions['classes.internalClass.markers'])); + Assert::same([ + '`final_internal_class` with an empty `include` makes every class final; `classes.internalClass.markers` cannot be empty, so it stays `@internal`.', + '`final_internal_class` makes a class without a doc comment final too, which DressCode does not.', + ], $translation->warnings); +}); + + +test('ordered_class_elements takes the kinds of any visibility, and stands an abstract or readonly one for its visibility', function () { + $translation = (new Translator)->translate(['ordered_class_elements' => ['order' => [ + 'use_trait', 'constant', 'property_static', 'property', 'method_public_abstract', 'method_public', 'method_abstract', 'phpunit', + ]]]); + Assert::same( + ['traitUse', 'constant', 'publicStaticProperty', 'protectedStaticProperty', 'privateStaticProperty', 'property', 'publicMethod'], + $translation->decisions['classes.members.order'], + ); + Assert::same([ + 'DressCode orders an abstract or a readonly member with the others of its visibility, so `method_public_abstract` of `ordered_class_elements` stand for those.', + 'The kinds `method_abstract`, `phpunit` of `ordered_class_elements` have no equivalent in `classes.members.order` and were left out.', + ], $translation->warnings); +}); + + +test('a format of type hints told to check the doc comments sets their notation too', function () { + $sniff = 'SlevomatCodingStandard.TypeHints.UnionTypeHintFormat'; + $translation = (new Translator)->translate( + [$sniff => ['shortNullable' => 'no', 'nullPosition' => 'last', 'enableForDocComments' => true]], + ); + Assert::same( + [ + 'types.nullable' => 'union', + 'types.nullPosition' => 'last', + 'phpdoc.types.nullable' => 'union', + 'phpdoc.types.nullPosition' => 'last', + ], + $translation->decisions, + ); + Assert::same(['types.nullable' => 'questionMark'], (new Translator)->translate([$sniff => ['shortNullable' => 'yes']])->decisions); + + Assert::contains( + '`ordered_types` with `case_sensitive`', + implode("\n", (new Translator)->translate(['ordered_types' => ['case_sensitive' => true]])->warnings), + ); +}); + + +test('single_line_empty_body collapses an empty anonymous class braces_position would expand, in either order, and a closure', function () { + $expanded = ['braces_position' => ['allow_single_line_empty_anonymous_classes' => false]]; + Assert::same('expanded', (new Translator)->translate($expanded)->decisions['braces.emptyBody.anonymousClass']); + foreach ([[...$expanded, 'single_line_empty_body' => true], ['single_line_empty_body' => true, ...$expanded]] as $rules) { + Assert::same('collapsed', (new Translator)->translate($rules)->decisions['braces.emptyBody.anonymousClass']); + } + + // a closure is a function too + Assert::same('collapsed', (new Translator)->translate(['single_line_empty_body' => true])->decisions['braces.emptyBody.closure']); +}); + + +test('the space after `!` is not_operator_with_successor_space beside unary_operator_spaces, in either order', function () { + foreach ([ + ['unary_operator_spaces', 'not_operator_with_successor_space'], + ['not_operator_with_successor_space', 'unary_operator_spaces'], + ] as $order) { + $decisions = (new Translator)->translate(array_fill_keys($order, true))->decisions; + Assert::same('spaced', $decisions['spacing.logicalNot']); + Assert::same('compact', $decisions['spacing.unaryOperator']); + } + + Assert::same('compact', (new Translator)->translate(['unary_operator_spaces' => true])->decisions['spacing.logicalNot']); +}); + + +test('PER Coding Style of PHP CS Fixer leaves the parentheses around `new` alone unless a rule removes them', function () { + Assert::same('keep', (new Translator)->translate(['@PER-CS' => true])->decisions['classes.instantiationWrapping']); + Assert::same( + 'forbidden', + (new Translator)->translate(['@PER-CS' => true, '@PHP84Migration' => true])->decisions['classes.instantiationWrapping'], + ); + Assert::false(isset((new Translator)->translate(['@PSR12' => true])->decisions['classes.instantiationWrapping'])); +}); + + +test('an element with no rule of its own is dropped from the list, not passed on', function () { + // @Symfony asks for array_destructuring, which dresscode/trailingComma does not know + $translation = (new Translator)->translate([ + 'trailing_comma_in_multiline' => ['elements' => ['array_destructuring', 'arrays', 'match', 'parameters']], + ]); + Assert::same( + commaPlaces([ + 'array' => 'required', 'parameter' => 'required', 'matchArm' => 'required', 'argument' => 'keep', 'closureUse' => 'keep', + 'import' => 'keep', 'list' => 'keep', + ]), + $translation->decisions, + ); + Assert::same( + ['`trailing_comma_in_multiline` with `array_destructuring` is translated only for the short `[...]`, which `trailingComma` sets under the key `array`; a multi-line `list()` keeps its comma as written.'], + $translation->warnings, + ); +}); + + +test('a set of aliases PHP 8 no longer has is dropped from the list', function () { + $translation = (new Translator)->translate([ + 'no_alias_functions' => ['sets' => ['@internal', '@mbreg', '@exif']], + ]); + Assert::same(['hygiene.aliasFunctions' => ['core']], $translation->decisions); + Assert::same([ + '`no_alias_functions` with `@mbreg` has no equivalent; PHP 8 has none of the aliases it replaces.', + '`no_alias_functions` with `@exif` has no equivalent; PHP 8 has none of the aliases it replaces.', + ], $translation->warnings); +}); + + +test('options are translated, a rule switched off is turned off', function () { + $translation = (new Translator)->translate([ + 'cast_spaces' => ['space' => 'none'], + 'concat_space' => ['spacing' => 'one'], + 'yoda_style' => ['less_and_greater' => true], + 'elseif' => false, + 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'], + ]); + Assert::same([ + 'spacing.cast' => 'compact', + 'spacing.concatenation' => 'spaced', + 'expressions.comparison.yoda' => 'required', + 'controlFlow.elseif' => 'keep', + 'multiline.semicolon' => 'nextLine', + ], $translation->decisions); + Assert::same( + ['`yoda_style` decides `==`, `===`, `<` and `>` one by one, which DressCode decides together for the equality comparisons alone.'], + $translation->warnings, + ); + Assert::same( + ['literals.arraySyntax.array' => 'long', 'literals.arraySyntax.list' => 'long', 'expressions.assignment.increment' => 'postfix'], + (new Translator)->translate([ + 'array_syntax' => ['syntax' => 'long'], + 'list_syntax' => ['syntax' => 'long'], + 'increment_style' => ['style' => 'post'], + ])->decisions, + ); + // the visibility of the elements the fixer names alone, as Pint names methods and properties + Assert::same( + [ + 'classes.visibility.property' => 'required', + 'classes.visibility.method' => 'required', + 'classes.visibility.interfaceMethod' => 'required', + 'classes.modifiers.order' => 'canonical', + ], + (new Translator)->translate(['modifier_keywords' => ['elements' => ['method', 'property']]])->decisions, + ); +}); + + +test('a decision that needs the types of the code is left out and said so, a keep of it is not', function () { + $translation = (new Translator)->translate(['class_keyword' => true, 'array_syntax' => true]); + Assert::same(['literals.arraySyntax.array' => 'short'], $translation->decisions); + Assert::same( + ['`literals.classNameInString` needs the types of the code, so it is left out; set it together with `typeAnalysis: phpstan` where the project has PHPStan.'], + $translation->warnings, + ); + Assert::noError(fn() => resolveTranslation($translation)); + Assert::same(['literals.classNameInString' => 'keep'], (new Translator)->translate(['class_keyword' => false])->decisions); +}); + + +test('the kinds of ordered_class_elements are translated to the kinds of memberOrder', function () { + $translation = (new Translator)->translate( + ['ordered_class_elements' => ['order' => ['use_trait', 'constant_public', 'method_public_static', 'construct', 'magic']]], + ); + Assert::same( + ['classes.members.order' => ['traitUse', 'publicConstant', 'publicStaticMethod', 'constructor', 'magicMethod']], + $translation->decisions, + ); +}); + + +test('the kinds of ordered_class_elements without an equivalent are left out and named', function () { + $translation = (new Translator)->translate( + ['ordered_class_elements' => ['order' => [ + 'use_trait', 'case', 'method:__construct', 'public', 'method_public', + ]]], + ); + Assert::same( + ['classes.members.order' => ['traitUse', 'enumCase', 'publicMethod']], + $translation->decisions, + ); + Assert::same( + ['The kinds `method:__construct`, `public` of `ordered_class_elements` have no equivalent in `classes.members.order` and were left out.'], + $translation->warnings, + ); +}); + + +test('foreign rules covering one rule are merged, not overwritten', function () { + $translation = (new Translator)->translate([ + 'SlevomatCodingStandard.Arrays.TrailingArrayComma' => true, + 'SlevomatCodingStandard.Functions.RequireTrailingCommaInCall' => true, + 'no_trailing_comma_in_singleline' => true, + 'SlevomatCodingStandard.TypeHints.ParameterTypeHint' => true, + 'SlevomatCodingStandard.TypeHints.ReturnTypeHint' => true, + ]); + Assert::same( + commaPlaces([ + 'array' => 'required', 'argument' => 'required', 'list' => 'optional', 'import' => 'optional', 'parameter' => 'keep', + 'matchArm' => 'keep', 'closureUse' => 'keep', + ]), + array_filter($translation->decisions, fn(string $path) => str_starts_with($path, 'multiline.trailingComma.'), ARRAY_FILTER_USE_KEY), + ); + Assert::same( + [ + 'types.declaration.parameter' => 'required', + 'types.declaration.return' => 'required', + 'types.declaration.anonymousFunction' => 'required', + ], + array_filter($translation->decisions, fn(string $path) => str_starts_with($path, 'types.'), ARRAY_FILTER_USE_KEY), + ); + + // a place one of them requires stays required whichever comes first + $translation = (new Translator)->translate([ + 'no_trailing_comma_in_singleline' => true, + 'SlevomatCodingStandard.Arrays.TrailingArrayComma' => true, + ]); + Assert::same( + commaPlaces([ + 'argument' => 'optional', 'array' => 'required', 'list' => 'optional', 'import' => 'optional', 'parameter' => 'keep', + 'matchArm' => 'keep', 'closureUse' => 'keep', + ]), + $translation->decisions, + ); +}); + + +test('foreign rules deciding one thing come to the same translation in either order', function () { + $translate = fn(array $rules) => [ + (new Translator)->translate($rules), + (new Translator)->translate(array_reverse($rules, preserve_keys: true)), + ]; + + // a fixer of the space before a comma leaves its alignment to the one of the space after it + foreach ($translate(['no_whitespace_before_comma_in_array' => true, 'whitespace_after_comma_in_array' => true]) as $translation) { + Assert::same(['spacing.comma' => 'spaced', 'spacing.alignment.comma' => 'optional'], $translation->decisions); + } + + // an order set in full wins over the one place another rule only prefers, and two different orders contradict + foreach ($translate([ + 'ordered_class_elements' => ['order' => ['constant_public', 'use_trait']], + 'PSR12.Traits.UseDeclaration' => true, + ]) as $translation) { + Assert::same(['publicConstant', 'traitUse'], $translation->decisions['classes.members.order']); + } + + $translator = new Translator([ + 'a_order' => fn(array $o, Translation $t) => $t->setOrder('acme.order', ['a', 'b']), + 'b_order' => fn(array $o, Translation $t) => $t->setOrder('acme.order', ['b', 'a']), + ]); + foreach ([['a_order', 'b_order'], ['b_order', 'a_order']] as $order) { + Assert::same([], $translator->translate(array_fill_keys($order, true))->decisions); + } + + // what each rule separates adds up + foreach ($translate([ + 'SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition' => true, + 'SlevomatCodingStandard.Classes.DisallowMultiPropertyDefinition' => true, + ]) as $translation) { + Assert::equal( + ['classes.members.grouping.constant' => 'separated', 'classes.members.grouping.property' => 'separated'], + array_intersect_key( + $translation->decisions, + [ + 'classes.members.grouping.constant' => 1, + 'classes.members.grouping.property' => 1, + 'classes.members.grouping.traitUse' => 1, + ], + ), + ); + } + + // counts of blank lines widen to the range of both, a count a map gives included + foreach ($translate(['class_attributes_separation' => true, 'Squiz.WhiteSpace.FunctionSpacing' => true]) as $translation) { + Assert::same([1, 2], $translation->decisions['blankLines.aroundMethods']); + } + + foreach ($translate([ + 'blank_line_before_statement' => true, + 'SlevomatCodingStandard.ControlStructures.JumpStatementsSpacing' => true, + ]) as $translation) { + Assert::same([1, null], $translation->decisions['blankLines.beforeStatement']['return']); + Assert::same(1, $translation->decisions['blankLines.beforeStatement']['yield']); + } + + // values that contradict each other leave the decision out, and a keep a table writes gives way + foreach ($translate(['group_import' => true, 'single_import_per_statement' => true]) as $translation) { + Assert::false(isset($translation->decisions['imports.grouping.class'])); + Assert::contains( + 'The foreign rules give `imports.grouping.class` values that contradict each other; DressCode leaves it out, for the configuration to set by hand.', + $translation->warnings, + ); + } + + $translator = new Translator([ + 'a_keep' => ['acme.foo' => 'keep'], + 'a_set' => ['acme.foo' => 'x'], + 'a_other' => ['acme.foo' => 'y'], + ]); + Assert::same(['acme.foo' => 'x'], $translator->translate(['a_keep' => true, 'a_set' => true])->decisions); + Assert::same(['acme.foo' => 'x'], $translator->translate(['a_set' => true, 'a_keep' => true])->decisions); + foreach ([['a_set', 'a_keep', 'a_other'], ['a_other', 'a_set', 'a_keep'], ['a_keep', 'a_other', 'a_set']] as $order) { + Assert::same([], $translator->translate(array_fill_keys($order, true))->decisions); + } +}); + + +test('an option with no value of DressCode to write is left out and said so, not written to be refused', function () { + $translate = fn(array $rules) => (new Translator)->translate($rules); + + $translation = $translate( + ['SlevomatCodingStandard.ControlStructures.BlockControlStructureSpacing' => ['controlStructures' => ['if', 'case', 'default']]], + ); + Assert::same(['blankLines.beforeStatement' => ['if' => 1], 'blankLines.afterStatement' => ['if' => 1]], $translation->decisions); + Assert::same( + ['`SlevomatCodingStandard.ControlStructures.BlockControlStructureSpacing` with `case`, `default` has no equivalent; DressCode counts the blank lines before a `case` in `blankLines.betweenCases`.'], + $translation->warnings, + ); + Assert::noError(fn() => resolveTranslation($translation)); + + $translation = $translate( + ['SlevomatCodingStandard.ControlStructures.BlockControlStructureSpacing' => ['controlStructures' => ['case', 'default']]], + ); + Assert::same([], $translation->decisions); + Assert::count(1, $translation->warnings); + + foreach ([ + 'SlevomatCodingStandard.Classes.RequireMultiLineMethodSignature', + 'SlevomatCodingStandard.ControlStructures.RequireMultiLineCondition', + ] as $sniff) { + $translation = $translate([$sniff => ['minLineLength' => 0]]); + Assert::null($translation->lineLength); + Assert::match("`$sniff` with `minLineLength=0` has no equivalent; %a%", $translation->warnings[0] ?? ''); + Assert::noError(fn() => resolveTranslation($translation)); + } + + $translation = $translate(['no_alias_functions' => ['sets' => ['@snmp', '@IMAP']]]); + Assert::same(['hygiene.aliasFunctions' => ['imap']], $translation->decisions); + Assert::same( + ['`no_alias_functions` with `@snmp` has no equivalent; DressCode leaves the aliases of the SNMP functions alone.'], + $translation->warnings, + ); + Assert::noError(fn() => resolveTranslation($translation)); + + $translation = $translate(['numeric_literal_separator' => ['strategy' => 'no_separator']]); + Assert::same( + ['literals.digitSeparator.integer' => 'forbidden', 'literals.digitSeparator.fraction' => 'forbidden'], + $translation->decisions, + ); + Assert::same([], $translation->warnings); + + // an alias of PHP is written as its function, any other replacement is advice, and a ruleset writes no value as the + // text `null`; the default of the sniff without `delete`, which PHP has no function of + $translation = $translate(['Generic.PHP.ForbiddenFunctions' => ['forbiddenFunctions' => [ + 'delete' => 'null', + 'sizeof' => 'count', + 'mysql_query' => 'mysqli_query', + 'mktime' => 'time', + ]]]); + Assert::same( + [ + 'upgrading.api.replacedFunctions' => ['sizeof' => 'count'], + 'upgrading.api.forbiddenFunctions' => [ + 'delete', + 'mysql_query' => 'use `mysqli_query()` instead', + 'mktime' => 'use `time()` instead', + ], + ], + $translation->decisions, + ); + Assert::same( + ['upgrading.api.replacedFunctions' => ['sizeof' => 'count']], + $translate(['Generic.PHP.ForbiddenFunctions' => true])->decisions, + ); + + // the debugging functions are the rule of debugging output, and the names standing alone of two sniffs join the + // entries of a third one + $translation = $translate([ + 'Generic.PHP.ForbiddenFunctions' => ['forbiddenFunctions' => ['print_r' => 'null', 'acme_dump' => 'null']], + 'Squiz.PHP.DiscouragedFunctions' => true, + 'Generic.PHP.SAPIUsage' => true, + ]); + Assert::same( + [ + 'upgrading.api.forbiddenFunctions' => ['print_r', 'acme_dump', 'error_log', 'php_sapi_name' => 'use `PHP_SAPI` instead'], + 'mistakes.debugOutput' => 'forbidden', + ], + $translation->decisions, + ); + Assert::same([], $translation->warnings); + + // a name standing alone gives way to an entry of the name, in either order and in any letter case + $forbidden = ['forbiddenFunctions' => ['Error_Log' => 'syslog']]; + foreach ([ + ['Generic.PHP.ForbiddenFunctions' => $forbidden, 'Squiz.PHP.DiscouragedFunctions' => true], + ['Squiz.PHP.DiscouragedFunctions' => true, 'Generic.PHP.ForbiddenFunctions' => $forbidden], + ] as $rules) { + $translation = $translate($rules); + Assert::same(['error_log' => 'use `syslog()` instead'], $translation->decisions['upgrading.api.forbiddenFunctions']); + Assert::noError(fn() => resolveTranslation($translation)); + } +}); + + +test('a translation of the trailing comma leaves the places it does not name as they are', function () { + $places = ['array', 'argument', 'parameter', 'matchArm', 'closureUse', 'import', 'list']; + $translation = (new Translator)->translate(['SlevomatCodingStandard.Functions.RequireTrailingCommaInDeclaration' => true]); + Assert::same(commaPlaces(['parameter' => 'required'] + array_fill_keys($places, 'keep')), $translation->decisions); + + $rules = [ + 'no_trailing_comma_in_singleline' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arguments']], + 'SlevomatCodingStandard.Arrays.TrailingArrayComma' => true, + ]; + $expected = ['array' => 'required', 'argument' => 'required', 'list' => 'optional', 'import' => 'optional'] + array_fill_keys( + $places, 'keep', + ); + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::equal(commaPlaces($expected), (new Translator)->translate($order)->decisions); + } +}); + + +test('a translation of empty parentheses leaves the places it does not name as they are', function () { + $translation = (new Translator)->translate(['attribute_empty_parentheses' => true]); + Assert::equal(['classes.emptyParentheses.attribute' => 'forbidden'], $translation->decisions); + + $rules = ['new_with_parentheses' => ['anonymous_class' => false], 'attribute_empty_parentheses' => ['use_parentheses' => true]]; + $expected = [ + 'classes.emptyParentheses.instantiation' => 'required', + 'classes.emptyParentheses.anonymousClass' => 'forbidden', + 'classes.emptyParentheses.attribute' => 'required', + ]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::equal($expected, (new Translator)->translate($order)->decisions); + } + + // the sniff removes the parentheses of a named class and passes an anonymous one by + $translation = (new Translator)->translate(['SlevomatCodingStandard.ControlStructures.NewWithoutParentheses' => true]); + Assert::equal(['classes.emptyParentheses.instantiation' => 'forbidden'], $translation->decisions); +}); + + +test('a sniff of the class declaration puts each interface of a multiline list on its own line', function () { + foreach (['PSR2.Classes.ClassDeclaration', 'PSR12.Classes.AnonClassDeclaration'] as $sniff) { + $translation = (new Translator)->translate([$sniff => true]); + Assert::same('perLine', $translation->decisions['multiline.items.implements']); + Assert::same('apart', $translation->decisions['multiline.edges.implements']); + Assert::same([], $translation->warnings); + } +}); + + +test('a fixer of the case of one kind of native names leaves the other kinds as they are', function () { + Assert::equal( + ['casing.type' => 'lowercase'], + (new Translator)->translate(['native_type_declaration_casing' => true])->decisions, + ); + + $rules = ['native_function_casing' => true, 'class_reference_name_casing' => true]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::equal( + ['casing.class' => 'declared', 'casing.function' => 'declared'], + (new Translator)->translate($order)->decisions, + ); + } +}); + + +test('a fixer of the imports leaves the names of the global namespace to a fixer of those', function () { + $translate = fn(array $rules) => (new Translator)->translate($rules)->decisions; + Assert::same(['imports.leadingBackslash' => 'forbidden'], $translate(['no_leading_import_slash' => true])); + + $rules = ['no_leading_import_slash' => true, 'PhpCsFixerCustomFixers/no_leading_slash_in_global_namespace' => true]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::equal( + ['imports.leadingBackslash' => 'forbidden', 'qualification.leadingBackslash' => 'forbidden'], + $translate($order), + ); + } +}); + + +test('a fixer of the case of constants leaves the keywords alone', function () { + $translate = fn(array $rules) => (new Translator)->translate($rules)->decisions; + Assert::equal(['casing.trueFalseNull' => 'uppercase'], $translate(['constant_case' => ['case' => 'upper']])); + Assert::equal( + ['casing.keyword' => 'lowercase', 'casing.magicConstant' => 'uppercase'], + $translate(['magic_constant_casing' => true, 'lowercase_keywords' => true]), + ); +}); + + +test('the order of the types of a doc comment translates to the options typeNotation has too', function () { + Assert::equal( + ['phpdoc.types.unionOrder' => 'keep', 'phpdoc.types.nullPosition' => 'last'], + (new Translator)->translate(['phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none']])->decisions, + ); + Assert::equal( + ['types.unionOrder' => 'alphabetical', 'types.nullPosition' => 'keep'], + (new Translator)->translate(['ordered_types' => ['null_adjustment' => 'none']])->decisions, + ); +}); + + +test('a fixer of the names of doc types leaves the notation of an array alone, unless another one sets it', function () { + $translate = fn(array $rules) => (new Translator)->translate($rules)->decisions['phpdoc.types.array'] ?? null; + Assert::null($translate(['phpdoc_scalar' => true])); + + $rules = ['phpdoc_types' => true, 'phpdoc_array_type' => true]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::same('generic', $translate($order)); + } +}); + + +test('a fixer keeping group uses gives way to one making them', function () { + $translate = fn(array $rules) => (new Translator)->translate($rules)->decisions['imports.grouping.class'] ?? null; + Assert::same(['separated', 'grouped'], $translate(['single_import_per_statement' => ['group_to_single_imports' => false]])); + Assert::same('separated', $translate(['single_import_per_statement' => true])); + + // the sniffs expand a group use, or forbid the comma between its names + Assert::same('separated', $translate(['PSR2.Namespaces.UseDeclaration' => true])); + Assert::same('separated', $translate(['SlevomatCodingStandard.Namespaces.MultipleUsesPerLine' => true])); + Assert::same(['separated', 'combined'], $translate(['SlevomatCodingStandard.Namespaces.DisallowGroupUse' => true])); + + // a sniff setting one form narrows what another one allows, in either order + $rules = [ + 'SlevomatCodingStandard.Namespaces.DisallowGroupUse' => true, + 'SlevomatCodingStandard.Namespaces.MultipleUsesPerLine' => true, + ]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::same('separated', $translate($order)); + } + + $rules = ['SlevomatCodingStandard.Namespaces.DisallowGroupUse' => true, 'PSR2.Namespaces.UseDeclaration' => true]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::same('separated', $translate($order)); + } + + // a value outside what the other one allows still contradicts it + $rules = ['SlevomatCodingStandard.Namespaces.DisallowGroupUse' => true, 'group_import' => true]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + $translation = (new Translator)->translate($order); + Assert::false(isset($translation->decisions['imports.grouping.class'])); + Assert::contains( + 'The foreign rules give `imports.grouping.class` values that contradict each other; DressCode leaves it out, for the configuration to set by hand.', + $translation->warnings, + ); + } + + $rules = ['group_import' => true, 'single_import_per_statement' => ['group_to_single_imports' => false]]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::same('grouped', (new Translator)->translate($order)->decisions['imports.grouping.class']); + } +}); + + +test('a place the translation does not name keeps what the preset of the translation gives it', function () { + $preset = [ + 'array' => 'required', 'argument' => 'required', 'parameter' => 'required', 'matchArm' => 'required', 'closureUse' => 'required', + 'import' => 'optional', 'list' => 'optional', + ]; + $translate = fn(array $rules) => array_diff_key( + (new Translator)->translate($rules)->decisions, + ['classes.instantiationWrapping' => true], + ); + + // the set still asks for the comma of a multi-line list, the rule of one line adds nothing to that + Assert::equal(commaPlaces($preset), $translate(['@PER-CS' => true, 'no_trailing_comma_in_singleline' => true])); + + // the fixer named replaces what the set says of a multi-line list, and the set still removes the comma on one line + Assert::equal( + commaPlaces(['array' => 'required'] + array_fill_keys(array_keys($preset), 'optional')), + $translate(['@PER-CS' => true, 'trailing_comma_in_multiline' => ['elements' => ['arrays']]]), + ); + + // a standard that does not run the rule leaves the places alone + Assert::equal( + commaPlaces(['argument' => 'required'] + array_fill_keys(array_keys($preset), 'keep')), + $translate(['PSR12' => true, 'SlevomatCodingStandard.Functions.RequireTrailingCommaInCall' => true]), + ); +}); + + +test('options of a rule that takes none are reported, not dropped in silence', function () { + $translation = (new Translator)->translate(['no_spaces_around_offset' => ['positions' => ['inside']]]); + Assert::same(['spacing.padding.offset' => 'forbidden'], $translation->decisions); + Assert::same( + ['The options of `no_spaces_around_offset` were not translated; set them by hand, `dresscode explain spacing.padding.offset` describes them.'], + $translation->warnings, + ); +}); + + +test('a rule of blank lines translates to the places it counts, with the counts its options give', function () { + $translate = fn(array $rules) => (new Translator)->translate($rules); + Assert::same( + ['blankLines.afterNamespace' => 1, 'blankLines.beforeDeclaration' => 1], + $translate(['PSR2.Namespaces.NamespaceDeclaration' => true])->decisions, + ); + Assert::same( + ['blankLines.afterImports' => 1, 'blankLines.beforeDeclaration' => 1], + $translate(['single_line_after_imports' => true])->decisions, + ); + Assert::same( + ['blankLines.aroundMethods' => [1, 2], 'blankLines.aroundInterfaceMethods' => [1, 2]], + $translate(['SlevomatCodingStandard.Classes.MethodSpacing' => ['maxLinesCount' => 2]])->decisions, + ); + Assert::same( + [ + 'blankLines.betweenMembers.constant' => [0, 1], + 'blankLines.betweenMemberKinds' => [0, 1], + 'blankLines.beforeDocumentedMember' => 2, + ], + $translate( + ['SlevomatCodingStandard.Classes.ConstantSpacing' => [ + 'minLinesCountBeforeWithComment' => 2, + 'maxLinesCountBeforeWithComment' => 2, + ]], + )->decisions, + ); + + $translation = $translate( + ['class_attributes_separation' => ['elements' => ['method' => 'one', 'property' => 'only_if_meta', 'trait_import' => 'none']]], + ); + Assert::same([ + 'blankLines.aroundMethods' => 1, + 'blankLines.aroundInterfaceMethods' => 1, + 'blankLines.betweenTraitUses' => 0, + 'blankLines.betweenMembers.property' => 0, 'blankLines.betweenMemberKinds' => 0, + 'blankLines.beforeDocumentedMember' => 1, + ], $translation->decisions); + Assert::same([], $translation->warnings); + + $translation = $translate(['no_extra_blank_lines' => ['tokens' => ['curly_brace_block', 'return', 'use_trait', 'extra']]]); + Assert::same([ + 'blankLines.bodyStart.function' => 0, + 'blankLines.bodyStart.closure' => 0, + 'blankLines.bodyStart.propertyHook' => 0, + 'blankLines.bodyStart.controlStructure' => 0, + 'blankLines.bodyEnd.function' => 0, + 'blankLines.bodyEnd.closure' => 0, + 'blankLines.bodyEnd.propertyHook' => 0, + 'blankLines.bodyEnd.controlStructure' => 0, + 'blankLines.betweenTraitUses' => 0, + ], $translation->decisions); + Assert::same([ + '`no_extra_blank_lines` with `return` removes the blank lines after the jump, which stand only before the closing brace or the next `case`, what follows a jump in its block being unreachable; set `blankLines.bodyEnd.controlStructure` and `blankLines.bodyEnd.switchCase` for those.', + '`no_extra_blank_lines` with `extra` has no equivalent; DressCode counts the blank lines place by place in `blankLines`, with no limit of their own for these.', + ], $translation->warnings); + + $translation = $translate(['no_extra_blank_lines' => ['tokens' => ['attribute']]]); + Assert::same(['blankLines.afterAttributes' => 0], $translation->decisions); + Assert::same([], $translation->warnings); + + // a sniff of control structures asks for a blank line after one at least, and checks only their own braces and parentheses + $translation = $translate(['Squiz.WhiteSpace.ControlStructureSpacing' => true]); + Assert::same([1, null], $translation->decisions['blankLines.afterStatement']['if']); + Assert::same([0, 'forbidden'], [ + $translation->decisions['blankLines.bodyStart.controlStructure'], + $translation->decisions['spacing.padding.controlStructure'], + ]); + Assert::same([], $translation->warnings); + + // the defaults of the fixer keep enum cases together, which is not worth a warning, a separation chosen is + $translation = $translate(['class_attributes_separation' => true]); + $members = [ + 'blankLines.betweenMembers.enumCase' => 0, 'blankLines.betweenMembers.constant' => 1, 'blankLines.betweenMembers.property' => 1, + 'blankLines.betweenMemberKinds' => [0, 1], 'blankLines.beforeDocumentedMember' => [0, 1], + ]; + Assert::same($members, array_intersect_key($translation->decisions, $members)); + Assert::same([], $translation->warnings); + $translation = $translate(['class_attributes_separation' => ['elements' => ['const' => 'none', 'property' => 'one']]]); + Assert::same([ + 'blankLines.betweenMembers.constant' => 0, 'blankLines.betweenMembers.property' => 1, + 'blankLines.betweenMemberKinds' => [0, 1], 'blankLines.beforeDocumentedMember' => [0, 1], + ], $translation->decisions); + Assert::match( + '`class_attributes_separation` separates constants, properties and enum cases differently, %a%', + $translation->warnings[0] ?? '', + ); + + // two sniffs counting the members differently keep a count each, and widen the one they share to the range of both, + // in either order + $rules = [ + 'SlevomatCodingStandard.Classes.ConstantSpacing' => true, + 'SlevomatCodingStandard.Classes.PropertySpacing' => [ + 'minLinesCountBeforeWithoutComment' => 1, + 'maxLinesCountBeforeWithoutComment' => 2, + ], + ]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + $translation = $translate($order); + $decisions = $translation->decisions; + ksort($decisions); + Assert::same([ + 'blankLines.beforeDocumentedMember' => 1, 'blankLines.betweenMemberKinds' => [0, 2], + 'blankLines.betweenMembers.constant' => [0, 1], 'blankLines.betweenMembers.property' => [1, 2], + ], $decisions); + Assert::same( + ['The foreign rules count the blank lines of `blankLines.betweenMemberKinds` differently; DressCode has one count there and takes the range of them all.'], + $translation->warnings, + ); + } + + $translation = $translate([ + 'SlevomatCodingStandard.Classes.ConstantSpacing' => true, + 'SlevomatCodingStandard.Classes.PropertySpacing' => true, + ]); + Assert::same([ + 'blankLines.betweenMembers.constant' => [0, 1], 'blankLines.betweenMemberKinds' => [0, 1], + 'blankLines.beforeDocumentedMember' => 1, 'blankLines.betweenMembers.property' => [0, 1], + ], $translation->decisions); + Assert::same([], $translation->warnings); + + // imports of different kinds keep the blank line another fixer asks for, in either order + $rules = ['no_extra_blank_lines' => ['tokens' => ['use']], 'blank_line_between_import_groups' => true]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::same(1, $translate($order)->decisions['blankLines.betweenImportKinds']); + } +}); + + +test('a fixer of a declaration translates the parentheses, the comma and the brace of a function', function () { + $translate = fn(array $rules) => (new Translator)->translate($rules); + $translation = $translate(['function_declaration' => true]); + Assert::same('forbidden', $translation->decisions['spacing.padding.argument']); + Assert::same('optional', $translation->decisions['multiline.trailingComma.parameter']); + Assert::same('spaced', $translation->decisions['spacing.anonymousFunction']); + Assert::same([], $translation->warnings); + + // a closure without the space keeps the space after every other keyword + $translation = $translate(['function_declaration' => ['closure_function_spacing' => 'none']]); + Assert::same('compact', $translation->decisions['spacing.anonymousFunction']); + Assert::same('required', $translation->decisions['spacing.keyword']); + Assert::same([], $translation->warnings); + + // a comma another fixer requires stays, in either order + $rules = ['function_declaration' => true, 'trailing_comma_in_multiline' => ['elements' => ['parameters']]]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::same('required', $translate($order)->decisions['multiline.trailingComma.parameter']); + } + + $translation = $translate(['function_declaration' => ['trailing_comma_single_line' => true]]); + Assert::false(isset($translation->decisions['multiline.trailingComma.parameter'])); + Assert::same( + ['`function_declaration` with `trailing_comma_single_line=true` has no equivalent; DressCode removes the trailing comma of parameters on one line.'], + $translation->warnings, + ); + + $translation = $translate(['braces_position' => true]); + Assert::same( + ['nextLine', 'sameLine'], + [$translation->decisions['braces.position.function'], $translation->decisions['braces.position.multilineSignature']], + ); + $translation = $translate(['braces_position' => ['functions_opening_brace' => 'same_line']]); + Assert::same( + ['sameLine', 'sameLine'], + [$translation->decisions['braces.position.function'], $translation->decisions['braces.position.multilineSignature']], + ); + // the older name of the fixer + $translation = $translate( + ['curly_braces_position' => ['functions_opening_brace' => 'same_line', 'classes_opening_brace' => 'same_line']], + ); + Assert::same( + ['sameLine', 'sameLine'], + [$translation->decisions['braces.position.function'], $translation->decisions['braces.position.class']], + ); + + // the destructuring of the fixer alone has no place of its own + $translation = $translate(['trailing_comma_in_multiline' => ['elements' => ['array_destructuring']]]); + Assert::same([], $translation->decisions); + Assert::match( + '`trailing_comma_in_multiline` with `array_destructuring` and without `arrays` has no equivalent; %a%', + $translation->warnings[0] ?? '', + ); + + Assert::match( + '`Squiz.ControlStructures.ForEachLoopDeclaration` also puts a single space around the `=>` of a `foreach`, %a%', + $translate(['Squiz.ControlStructures.ForEachLoopDeclaration' => true])->warnings[0] ?? '', + ); +}); + + +test('the fixers of the indentation and the line ending write those of the configuration object', function () { + $translation = (new Translator)->translate(['indentation_type' => true, 'statement_indentation' => true, 'line_ending' => true]); + Assert::same(['indentation.unit' => '4 spaces', 'file.lineEnding' => 'LF'], $translation->decisions); + Assert::same([], $translation->warnings); + + $translation = (new Translator)->translate(['indentation_type' => true, 'line_ending' => true], "\t", "\r\n"); + Assert::same(['indentation.unit' => 'tab', 'file.lineEnding' => 'CRLF'], $translation->decisions); + + $translation = (new Translator)->translate(['indentation_type' => true], ' '); + Assert::same([], $translation->decisions); + Assert::same( + ["The fixers of the indentation indent by the `indent` ' ' of the configuration, which has no equivalent; DressCode indents by a tab, four spaces or two."], + $translation->warnings, + ); + + $dir = createTempDir('translator'); + file_put_contents( + "$dir/fixer.php", + " true]; }\n\tpublic function getIndent(): string { return \"\\t\"; }\n\tpublic function getLineEnding(): string { return \"\\r\\n\"; }\n};\n", + ); + Assert::equal( + new ForeignRules(['indentation_type' => true], indent: "\t", lineEnding: "\r\n"), + PhpCsFixer::readConfig("$dir/fixer.php"), + ); + file_put_contents("$dir/fixer.php", " true];\n"); + Assert::equal(new ForeignRules(['indentation_type' => true]), PhpCsFixer::readConfig("$dir/fixer.php")); +}); + + +test('a sniff of the indentation translates to the unit it names, in either order', function () { + $translate = fn(array $rules) => (new Translator)->translate($rules)->decisions; + Assert::same(['indentation.unit' => 'tab'], $translate(['Generic.WhiteSpace.DisallowSpaceIndent' => true])); + Assert::same(['indentation.unit' => '4 spaces'], $translate(['Generic.WhiteSpace.DisallowTabIndent' => true])); + $rules = ['Generic.WhiteSpace.DisallowTabIndent' => true, 'Generic.WhiteSpace.ScopeIndent' => ['indent' => 2]]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + Assert::same(['indentation.unit' => '2 spaces'], $translate($order)); + } + + // a ruleset that trades the sniff of PSR12 against tabs keeps the scope indent with its defaults, which say no unit + $rules = [ + 'PSR12' => true, + 'Generic.WhiteSpace.DisallowTabIndent' => false, + 'Generic.WhiteSpace.ScopeIndent' => ['ignoreIndentationTokens' => ['T_OPEN_TAG']], + 'Generic.WhiteSpace.DisallowSpaceIndent' => true, + ]; + Assert::same('tab', $translate($rules)['indentation.unit'] ?? null); + Assert::same('tab', $translate(['Generic.WhiteSpace.ScopeIndent' => ['tabIndent' => true]])['indentation.unit'] ?? null); +}); + + +test('a line limit given alone is the widest line, the absolute limit given beside it wins', function () { + $lineLength = fn(array $properties) => (new Translator)->translate(['Generic.Files.LineLength' => $properties])->lineLength; + Assert::same(100, $lineLength([])); + Assert::same(200, $lineLength(['lineLimit' => 200])); + Assert::same(150, $lineLength(['lineLimit' => 120, 'absoluteLineLimit' => 150])); + Assert::same(120, $lineLength(['lineLimit' => 120, 'absoluteLineLimit' => 0])); +}); + + +test('a name of another tool stands for the decisions it sets', function () { + $translator = new Translator; + Assert::same(['spacing.cast'], $translator->findPaths('cast_spaces')); + Assert::same(['literals.arraySyntax.array'], $translator->findPaths('array_syntax')); + Assert::same([], $translator->findPaths('no_such_fixer')); + // and for those it sets under other options than its defaults + Assert::same( + ['upgrading.api.replacedFunctions', 'upgrading.api.forbiddenFunctions'], + $translator->findPaths('Generic.PHP.ForbiddenFunctions'), + ); + Assert::contains('Squiz.Scope.MethodScope', $translator->findForeignNames($translator->findPaths('Squiz.Scope.MethodScope'))); + Assert::contains('cast_spaces', $translator->findForeignNames(['spacing.cast'])); + Assert::same([], $translator->findForeignNames(['no.such.decision'])); +}); + + +test('a translator over tables of its own', function () { + $translator = new Translator( + [ + 'foo_bar' => ['acme.foo' => 'x'], + 'Acme.Bar' => fn(array $options, Translation $t) => $t + ->set('acme.bar', $options['x'] ?? 1) + ->set('acme.foo', 'x'), + ], + ['@Acme' => 'acme/preset'], + ); + $translation = $translator->translate(['@Acme' => true, 'Acme.Bar' => ['x' => 2], 'cast_spaces' => true]); + Assert::same(['acme/preset'], $translation->presets); + Assert::same(['acme.bar' => 2, 'acme.foo' => 'x'], $translation->decisions); + Assert::same(['No DressCode rule covers `cast_spaces`.'], $translation->warnings); + Assert::same(['acme.bar', 'acme.foo'], $translator->findPaths('Acme.Bar')); + Assert::same(['foo_bar', 'Acme.Bar'], $translator->findForeignNames(['acme.foo'])); +}); + + +test('a rule switched off makes what only it stands for keep, and leaves what another rule of the tool stands for too', function () { + $translator = new Translator( + [ + 'a_one' => ['controlFlow.elseif' => 'oneWord'], + 'a_two' => ['controlFlow.elseif' => 'oneWord'], + 'b_only' => ['file.closingTagAtEnd' => 'forbidden'], + 'Acme.Cat.A' => ['controlFlow.elseif' => 'oneWord'], + ], + ['@Acme' => 'acme/preset'], + ); + $translation = $translator->translate(['@Acme' => true, 'a_one' => false, 'b_only' => false]); + Assert::same(['file.closingTagAtEnd' => 'keep'], $translation->decisions); + Assert::same( + ['`a_one` is turned off, but `controlFlow.elseif` also stands for `a_two`, so it stays; set it to `keep` if none of them applies.'], + $translation->warnings, + ); + Assert::contains('closingTagAtEnd', $translation->toDraft()->toPhp()); + Assert::notContains('elseif', $translation->toDraft()->toPhp()); + + // a sniff shares nothing with a fixer, and what some foreign rule sets stays set in either order + Assert::same(['controlFlow.elseif' => 'keep'], $translator->translate(['Acme.Cat.A' => false])->decisions); + Assert::same(['controlFlow.elseif' => 'oneWord'], $translator->translate(['a_one' => true, 'Acme.Cat.A' => false])->decisions); + Assert::same(['controlFlow.elseif' => 'oneWord'], $translator->translate(['Acme.Cat.A' => false, 'a_one' => true])->decisions); + + // an exclusion of one message of a sniff cannot narrow what the sniff stands for + $translation = $translator->translate(['Acme.Cat.A.Message' => false]); + Assert::same([], $translation->decisions); + Assert::same( + ['`Acme.Cat.A.Message` is excluded, but `controlFlow.elseif` cannot leave out that check alone, so it stays as the rest of the configuration sets it.'], + $translation->warnings, + ); +}); + + +test('a rule switched off under a set stays off in the configuration the translation writes', function () { + $resolved = resolveTranslation((new Translator)->translate(['@PSR12' => true, 'no_closing_tag' => false])); + Assert::true($resolved->decisions['file.closingTagAtEnd']->value->isKept()); + Assert::false(resolveTranslation((new Translator)->translate(['@PSR12' => true]))->decisions['file.closingTagAtEnd']->value->isKept()); + + // declare_equal_normalize shares its decision with declare_parentheses, so it is said, not done + $translation = (new Translator)->translate(['@PSR12' => true, 'declare_equal_normalize' => false]); + Assert::same([], $translation->decisions); + Assert::match( + '`declare_equal_normalize` is turned off, but `spacing.declare` also stands for `declare_parentheses%a%', + $translation->warnings[0] ?? '', + ); +}); + + +test('a ruleset switches off a sniff it excludes inside a rule, and says it leaves out the paths a set excludes', function () { + $dir = createTempDir('translator'); + file_put_contents("$dir/phpcs.xml", <<<'XX' + + + tests/* + + + legacy/* + + + XX); + $foreign = PhpCodeSniffer::readConfig("$dir/phpcs.xml"); + Assert::same(['PSR12' => true, 'PSR2.Classes.ClassDeclaration' => false], $foreign->rules); + Assert::same(['The paths excluded from `PSR12` are not carried over; turn its rules off there with an override.'], $foreign->warnings); + + file_put_contents("$dir/phpcs.xml", '0'); + Assert::equal(new ForeignRules(['Squiz.Scope.MethodScope' => false]), PhpCodeSniffer::readConfig("$dir/phpcs.xml")); +}); + + +test('a ruleset that is missing or broken is said so', function () { + $dir = createTempDir('translator'); + Assert::exception( + fn() => PhpCodeSniffer::readConfig("$dir/phpcs.xml"), + ConfigurationException::class, + 'File `%a%phpcs.xml` does not exist.', + ); + + file_put_contents("$dir/phpcs.xml", "\n\n"); + Assert::exception( + fn() => PhpCodeSniffer::readConfig("$dir/phpcs.xml"), + ConfigurationException::class, + 'File `%a%phpcs.xml` is not a PHP_CodeSniffer ruleset: Opening and ending tag mismatch: rule line 2 and ruleset on line 3.', + ); + + // well formed XML of another root would migrate as no rules at all + file_put_contents("$dir/phpcs.xml", ''); + Assert::exception( + fn() => PhpCodeSniffer::readConfig("$dir/phpcs.xml"), + ConfigurationException::class, + 'File `%a%phpcs.xml` is not a PHP_CodeSniffer ruleset: its root is ``, not ``.', + ); +}); + + +test('the sniffs of PSR12 listed one by one translate, all but four that no rule covers', function () { + $sniffs = [ + 'Generic.ControlStructures.InlineControlStructure', 'Generic.Files.ByteOrderMark', 'Generic.Files.LineEndings', + 'Generic.Files.LineLength', 'Generic.Formatting.DisallowMultipleStatements', 'Generic.Functions.FunctionCallArgumentSpacing', + 'Generic.NamingConventions.UpperCaseConstantName', 'Generic.PHP.DisallowAlternativePHPTags', 'Generic.PHP.DisallowShortOpenTag', + 'Generic.PHP.LowerCaseConstant', 'Generic.PHP.LowerCaseKeyword', 'Generic.PHP.LowerCaseType', + 'Generic.WhiteSpace.DisallowTabIndent', 'Generic.WhiteSpace.IncrementDecrementSpacing', 'Generic.WhiteSpace.ScopeIndent', + 'PEAR.Functions.ValidDefaultValue', + 'PSR1.Classes.ClassDeclaration', 'PSR1.Files.SideEffects', 'PSR1.Methods.CamelCapsMethodName', + 'PSR2.Classes.ClassDeclaration', 'PSR2.Classes.PropertyDeclaration', 'PSR2.ControlStructures.ElseIfDeclaration', + 'PSR2.ControlStructures.SwitchDeclaration', 'PSR2.Files.ClosingTag', 'PSR2.Files.EndFileNewline', + 'PSR2.Methods.FunctionCallSignature', 'PSR2.Methods.FunctionClosingBrace', 'PSR2.Methods.MethodDeclaration', + 'PSR12.Classes.AnonClassDeclaration', 'PSR12.Classes.ClassInstantiation', 'PSR12.Classes.ClosingBrace', + 'PSR12.Classes.OpeningBraceSpace', 'PSR12.ControlStructures.BooleanOperatorPlacement', + 'PSR12.ControlStructures.ControlStructureSpacing', 'PSR12.Files.DeclareStatement', 'PSR12.Files.FileHeader', + 'PSR12.Files.ImportStatement', 'PSR12.Files.OpenTag', 'PSR12.Functions.NullableTypeDeclaration', + 'PSR12.Functions.ReturnTypeDeclaration', 'PSR12.Keywords.ShortFormTypeKeywords', 'PSR12.Namespaces.CompoundNamespaceDepth', + 'PSR12.Operators.OperatorSpacing', 'PSR12.Properties.ConstantVisibility', 'PSR12.Traits.UseDeclaration', + 'Squiz.Classes.ValidClassName', 'Squiz.ControlStructures.ControlSignature', 'Squiz.ControlStructures.ForEachLoopDeclaration', + 'Squiz.ControlStructures.ForLoopDeclaration', 'Squiz.ControlStructures.LowercaseDeclaration', 'Squiz.Functions.FunctionDeclaration', + 'Squiz.Functions.FunctionDeclarationArgumentSpacing', 'Squiz.Functions.LowercaseFunctionKeywords', + 'Squiz.Functions.MultiLineFunctionDeclaration', 'Squiz.Scope.MethodScope', 'Squiz.WhiteSpace.CastSpacing', + 'Squiz.WhiteSpace.ControlStructureSpacing', 'Squiz.WhiteSpace.ScopeClosingBrace', 'Squiz.WhiteSpace.ScopeKeywordSpacing', + 'Squiz.WhiteSpace.SuperfluousWhitespace', + ]; + Assert::count(60, $sniffs); + $translation = (new Translator)->translate(array_fill_keys($sniffs, true)); + $uncovered = [ + 'Generic.PHP.DisallowAlternativePHPTags', + 'PSR1.Classes.ClassDeclaration', + 'PSR1.Files.SideEffects', + 'PSR12.Namespaces.CompoundNamespaceDepth', + ]; + Assert::same( + $uncovered, + array_values(array_filter($sniffs, fn($sniff) => in_array("No DressCode rule covers `$sniff`.", $translation->warnings, true))), + ); + + // what this one checks DressCode does by decisions that say more, so it says why instead + $explained = ['Squiz.WhiteSpace.ScopeClosingBrace']; + $translator = new Translator; + foreach ($sniffs as $sniff) { + Assert::same(in_array($sniff, [...$uncovered, ...$explained], true), $translator->findPaths($sniff) === [], $sniff); + } + + Assert::noError(fn() => resolveTranslation($translation)); + Assert::same('required', $translation->decisions['classes.emptyParentheses.instantiation']); + Assert::false(isset($translation->decisions['classes.emptyParentheses.anonymousClass'])); + Assert::same(['traitUse'], $translation->decisions['classes.members.order']); + Assert::same('separated', $translation->decisions['classes.members.grouping.traitUse']); + Assert::same('spaced', $translation->decisions['spacing.concatenation']); +}); + + +test('the properties of the sniffs of PSR12 translate, or say they have no equivalent', function () { + Assert::same( + ['multiline.operatorPosition.condition' => 'lineStart'], + (new Translator)->translate(['PSR12.ControlStructures.BooleanOperatorPlacement' => ['allowOnly' => 'first']])->decisions, + ); + + // without `allowOnly` either place is allowed, which a preset requiring one does not override + $translation = (new Translator)->translate(['PSR12' => true, 'PSR12.ControlStructures.BooleanOperatorPlacement' => true]); + Assert::same(['multiline.operatorPosition.condition' => 'keep'], $translation->decisions); + Assert::true(resolveTranslation($translation)->decisions['multiline.operatorPosition.condition']->value->isKept()); + + Assert::same( + ['multiline.operatorPosition.condition' => 'lineEnd'], + (new Translator)->translate(['PSR12.ControlStructures.BooleanOperatorPlacement' => ['allowOnly' => 'last']])->decisions, + ); + + $translation = (new Translator)->translate([ + 'PSR2.Methods.FunctionCallSignature' => ['allowMultipleArguments' => true, 'requiredSpacesAfterOpen' => 1], + 'Squiz.ControlStructures.ForLoopDeclaration' => ['requiredSpacesBeforeClose' => 1], + ]); + Assert::same([ + '`PSR2.Methods.FunctionCallSignature` with spaces inside the parentheses has no equivalent; DressCode writes none.', + '`Squiz.ControlStructures.ForLoopDeclaration` with spaces inside the parentheses has no equivalent; DressCode writes none.', + ], $translation->warnings); + Assert::same('fill', $translation->decisions['multiline.items.argument']); + + $translation = (new Translator)->translate(['method_argument_space' => ['on_multiline' => 'ignore']]); + Assert::same([], $translation->warnings); + Assert::false(isset($translation->decisions['multiline.items.argument'])); +}); + + +test('a message of a sniff with a row of its own translates alone', function () { + $translation = (new Translator)->translate(['PSR1.Methods.CamelCapsMethodName.NotCamelCaps' => true]); + Assert::same(['naming.method' => 'camelCase'], $translation->decisions); + Assert::same([], $translation->warnings); +}); + + +test('a message excluded from a sniff turns off its rule, which the sniff covers through that message', function () { + $translation = (new Translator)->translate(['PSR12' => true, 'PSR2.ControlStructures.SwitchDeclaration.SpaceBeforeColonCASE' => false]); + Assert::same(['spacing.switchCase' => 'keep'], $translation->decisions); + Assert::same([], $translation->warnings); + + // excluded while the configuration turns its sniff on by name, it takes what it stands for from that sniff + $rules = [ + 'PSR2.ControlStructures.SwitchDeclaration' => true, + 'PSR2.ControlStructures.SwitchDeclaration.SpaceBeforeColonCASE' => false, + ]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + $translation = (new Translator)->translate($order); + Assert::same('keep', $translation->decisions['spacing.switchCase']); + Assert::same('forbidden', $translation->decisions['upgrading.php.caseSemicolon']); + } + + $rules = [ + 'Squiz.NamingConventions.ValidVariableName' => true, + 'Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps' => false, + ]; + foreach ([$rules, array_reverse($rules, preserve_keys: true)] as $order) { + $translation = (new Translator)->translate($order); + Assert::same('keep', $translation->decisions['naming.property']); + Assert::same('camelCase', $translation->decisions['naming.variable']); + } + + // the sniff excluded whole turns off what only it and its messages cover, and says what another sniff covers too + $translation = (new Translator)->translate(['PSR12' => true, 'PSR2.ControlStructures.SwitchDeclaration' => false]); + Assert::same( + [ + 'upgrading.php.caseSemicolon' => 'keep', + 'spacing.switchCase' => 'keep', + 'controlFlow.switch.fallThroughComment' => 'keep', + 'indentation.switchCase' => 'keep', + ], + $translation->decisions, + ); + Assert::match( + '`PSR2.ControlStructures.SwitchDeclaration` is turned off, but `casing.keyword`, %a% also stand for `Generic.PHP.LowerCaseKeyword`, `Generic.PHP.LowerCaseType`%a%', + $translation->warnings[0] ?? '', + ); + + // a sniff turned off in the configuration stands for nothing there + $translation = (new Translator)->translate([ + 'PSR12' => true, + 'Generic.WhiteSpace.DisallowTabIndent' => false, + 'Generic.WhiteSpace.DisallowSpaceIndent' => false, + ]); + Assert::same([ + '`Generic.WhiteSpace.DisallowTabIndent` is turned off, but `indentation.unit` also stands for `Generic.WhiteSpace.ScopeIndent`, so it stays; set it to `keep` if none of them applies.', + '`Generic.WhiteSpace.DisallowSpaceIndent` is turned off, but `indentation.unit` also stands for `Generic.WhiteSpace.ScopeIndent`, so it stays; set it to `keep` if none of them applies.', + ], $translation->warnings); + + // and without a standard only the sniffs the configuration names run + $translation = (new Translator)->translate(['Generic.WhiteSpace.DisallowTabIndent' => false, 'Squiz.Scope.MethodScope' => true]); + Assert::same(['indentation.unit' => 'keep'], array_intersect_key($translation->decisions, ['indentation.unit' => 1])); + Assert::same([], $translation->warnings); +}); + + +test('a .php-cs-fixer.php that cannot be run is an error of the configuration, not the end of the run', function () { + $dir = createTempDir('translator'); + + // a class it misses is most likely PHP CS Fixer, which only the dresscode of the project sees + file_put_contents("$dir/.php-cs-fixer.php", "setRules(['@PSR12' => true]);\n"); + Assert::exception( + fn() => PhpCsFixer::readConfig("$dir/.php-cs-fixer.php"), + ConfigurationException::class, + 'File `%a%.php-cs-fixer.php` needs the classes of PHP CS Fixer, which the autoload of its project does not load: Class "Acme\\Missing\\Config" not found.', + ); + + // any other error of the file is said as it is + file_put_contents("$dir/.php-cs-fixer.php", " PhpCsFixer::readConfig("$dir/.php-cs-fixer.php"), + ConfigurationException::class, + "File `%a%.php-cs-fixer.php` cannot be read: Unclosed '[' on line 2.", + ); + + // what it writes or does to its process stays in its own one + file_put_contents("$dir/.php-cs-fixer.php", " true];\n"); + $cwd = getcwd(); + Assert::equal(new ForeignRules(['indentation_type' => true]), PhpCsFixer::readConfig("$dir/.php-cs-fixer.php")); + Assert::same($cwd, getcwd()); + file_put_contents("$dir/.php-cs-fixer.php", " PhpCsFixer::readConfig("$dir/.php-cs-fixer.php"), + ConfigurationException::class, + 'File `%a%` cannot be read: %a%.', + ); +}); + + +test('the configuration is written as a dresscode.php, a rule written as its decisions by them', function () { + $translation = (new Translator)->translate(['@PSR12' => true, 'cast_spaces' => ['space' => 'none'], 'elseif' => true]); + Assert::same( + " [\n" + . "\t\t\t'cast' => '(int)\$x',\n" + . "\t\t],\n" + . "\t\t'controlFlow' => [\n" + . "\t\t\t'elseif' => 'elseif',\n" + . "\t\t],\n" + . "\t],\n" + . ");\n", + $translation->toDraft()->toPhp(), + ); + + // a list of plain values stays on its line, an empty map too + $translation = (new Translator)->translate(['blank_line_before_statement' => ['statements' => ['return']], 'phpdoc_trim' => true]); + Assert::contains("\t\t\t'beforeStatement' => [\n\t\t\t\t'return' => '1+',\n\t\t\t],\n", $translation->toDraft()->toPhp()); + $translation->set('blankLines.afterStatement', []); + Assert::contains("\t\t\t'afterStatement' => [],\n", $translation->toDraft()->toPhp()); + + // a map with numbers for keys keeps them + $translation = new Translation; + $translation->set('acme.map', [2 => 'a', 5 => 'b']); + Assert::contains("\t\t\t'map' => [\n\t\t\t\t2 => 'a',\n\t\t\t\t5 => 'b',\n\t\t\t],\n", $translation->toDraft()->toPhp()); +}); + + +test('two foreign rules writing names merge into the decisions of the qualification they mean together', function () { + $translation = (new Translator)->translate(['global_namespace_import' => true, 'native_function_invocation' => true]); + Assert::equal([ + 'globalClass' => 'imported', + 'globalFunction' => 'backslashed', + 'globalConstant' => 'backslashed', // the fixer imports no constant by default + 'optimizedFunction' => 'backslashed', + ], $translation->qualification['shape']); + Assert::equal(['optimizedFunction' => 'qualified', 'function' => 'bare'], $translation->qualification['fallback']); + Assert::same([], $translation->decisions); + + Assert::same( + " [\n" + . "\t\t\t'class' => [\n" + . "\t\t\t\t'global' => 'imported',\n" + . "\t\t\t],\n" + . "\t\t\t'function' => [\n" + . "\t\t\t\t'optimized' => 'backslashed',\n" + . "\t\t\t\t'global' => 'bare',\n" + . "\t\t\t],\n" + . "\t\t\t'constant' => [\n" + . "\t\t\t\t'global' => ['backslashed', 'bare'],\n" + . "\t\t\t\t'optimized' => ['backslashed', 'bare'],\n" + . "\t\t\t],\n" + . "\t\t],\n" + . "\t],\n" + . ");\n", + $translation->toDraft()->toPhp(), + ); + Assert::contains('qualification.function.global', $translation->getPaths()); + Assert::noError(fn() => resolveTranslation($translation)); +}); + + +test('the casing of the classes and the functions of PHP says the decision covers those of the project too with the types', function () { + $translation = (new Translator)->translate(['class_reference_name_casing' => true, 'native_function_casing' => true]); + Assert::same(['casing.class' => 'declared', 'casing.function' => 'declared'], $translation->decisions); + Assert::same([ + '`class_reference_name_casing` writes the classes PHP declares, while `casing.class` writes those of the project and its packages too where the configuration sets `typeAnalysis`.', + '`native_function_casing` writes the functions PHP declares, while `casing.function` writes those of the project and its packages too where the configuration sets `typeAnalysis`.', + ], $translation->warnings); +}); + + +test('fully_qualified_strict_types writes a class of the namespace of the file bare and one of another namespace imported', function () { + $decisions = resolveTranslation((new Translator)->translate(['fully_qualified_strict_types' => ['import_symbols' => true]]))->decisions; + Assert::same('imported', $decisions['qualification.class.otherNamespace']->value->toWrittenData()); + Assert::same('bare', $decisions['qualification.class.currentNamespace']->value->toWrittenData()); + Assert::same('imported', $decisions['qualification.class.phpdoc']->value->toWrittenData()); + + // a doc comment only where the option leaves it some tags + $translation = (new Translator)->translate(['fully_qualified_strict_types' => ['phpdoc_tags' => []]]); + Assert::false(isset($translation->decisions['qualification.class.phpdoc'])); +}); + + +test('native_function_invocation takes the backslash from a function it does not write it before only with strict', function () { + $qualification = function (array $options): array { + $decisions = resolveTranslation((new Translator)->translate( + ['native_function_invocation' => $options + ['scope' => 'namespaced']], + ))->decisions; + return [ + $decisions['qualification.function.global']->value->toWrittenData(), + $decisions['qualification.function.optimized']->value->toWrittenData(), + ]; + }; + Assert::same(['bare', 'backslashed'], $qualification([])); + Assert::same(['keep', 'backslashed'], $qualification(['strict' => false])); + Assert::same(['backslashed', 'backslashed'], $qualification(['include' => ['@all']])); +}); + + +test('a function or a constant the fixers and the sniffs name one by one follows its group, which the translation says', function () { + $cases = [ + [['native_function_invocation' => ['include' => ['@all'], 'exclude' => ['dump'], 'scope' => 'namespaced']], + ['optimizedFunction' => 'backslashed', 'globalFunction' => 'backslashed'], + ['function' => 'qualified'], + ['`native_function_invocation` names functions one by one (`dump`), which DressCode decides by group; a function named follows `qualification.function.global`, or `qualification.function.optimized` where the compiler optimizes it.']], + [['native_function_invocation' => [ + 'include' => ['@compiler_optimized', 'dump'], + 'exclude' => ['var_dump'], + 'strict' => false, + 'scope' => 'namespaced', + ]], + ['optimizedFunction' => 'backslashed'], + ['optimizedFunction' => 'qualified'], + ['`native_function_invocation` names functions one by one (`dump`, `var_dump`), which DressCode decides by group; a function named follows `qualification.function.global`, or `qualification.function.optimized` where the compiler optimizes it.']], + // fix_built_in asks for the constants PHP declares, qualified where the compiler computes with them, and strict + // takes the backslash from the rest + [['native_constant_invocation' => ['scope' => 'namespaced']], + ['optimizedConstant' => 'backslashed'], + ['optimizedConstant' => 'qualified', 'constant' => 'bare'], + ['`native_constant_invocation` with `fix_built_in` writes the backslash before every constant of PHP; DressCode writes it before those the compiler computes with.']], + [['native_constant_invocation' => [ + 'fix_built_in' => false, + 'include' => ['PHP_EOL'], + 'exclude' => ['DEBUG', 'null'], + 'scope' => 'namespaced', + ]], + ['optimizedConstant' => 'backslashed'], + ['constant' => 'bare'], + ['`native_constant_invocation` names constants one by one (`PHP_EOL`, `DEBUG`), which DressCode decides by group; a constant named follows `qualification.constant.global`, or `qualification.constant.optimized` where the compiler computes with it.']], + // the special functions join an empty include instead of naming every function + [['SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalFunctions' => ['includeSpecialFunctions' => true]], + ['optimizedFunction' => 'backslashed'], + ['optimizedFunction' => 'qualified'], + []], + // an include names functions one by one, so nothing is translated but the warning + [['SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalFunctions' => ['include' => ['dump'], 'exclude' => ['var_dump']]], + [], + [], + ['`SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalFunctions` names functions one by one (`dump`, `var_dump`), which DressCode decides by group; a function named follows `qualification.function.global`, or `qualification.function.optimized` where the compiler optimizes it.']], + [['SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalConstants' => ['include' => ['PHP_EOL'], 'exclude' => ['DEBUG']]], + [], + [], + ['`SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalConstants` names constants one by one (`PHP_EOL`, `DEBUG`), which DressCode decides by group; a constant named follows `qualification.constant.global`.']], + [['SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly' => [ + 'allowFullyQualifiedGlobalFunctions' => true, + 'allowFallbackGlobalConstants' => false, + ]], + [ + 'class' => 'imported', + 'globalClass' => 'imported', + 'function' => 'imported', + 'globalFunction' => 'keep', + 'constant' => 'imported', + 'globalConstant' => 'imported', + ], + ['constant' => 'qualified'], + []], + ]; + foreach ($cases as [$rules, $notation, $fallback, $warnings]) { + $translation = (new Translator)->translate($rules); + Assert::equal($notation, $translation->qualification['shape']); + Assert::equal($fallback, $translation->qualification['fallback']); + Assert::same($warnings, $translation->warnings); + Assert::noError(fn() => resolveTranslation($translation)); + } +}); diff --git a/tests/DressCode/Measuring/Budget.phpt b/tests/DressCode/Measuring/Budget.phpt new file mode 100644 index 0000000000..630c81ff8c --- /dev/null +++ b/tests/DressCode/Measuring/Budget.phpt @@ -0,0 +1,41 @@ +files); + Assert::same(5, new Budget(450)->files); + Assert::same(20, new Budget(9000)->files); + Assert::same(7, new Budget(9000, 7)->files); +}); + + +test( + 'decisions are left unchecked until the rest fits, the one sparing the most files first, those the others stand on never', + function () { + $files = fn(string ...$names) => array_fill_keys($names, true); + $fixable = [ + 'blankLines.afterOpeningTag' => $files('a', 'b', 'c', 'd'), + 'spacing.cast' => $files('a', 'e'), + 'literals.quotes' => $files('f'), + 'indentation.unit' => $files('g', 'h', 'i'), + 'file.lineEnding' => $files('j', 'k', 'l'), + ]; + Assert::same(['blankLines.afterOpeningTag'], new Budget(100, 3)->findDropped($fixable, ['file.lineEnding'])); + Assert::same(['blankLines.afterOpeningTag', 'spacing.cast'], new Budget(100, 2)->findDropped($fixable, ['file.lineEnding'])); + Assert::same([], new Budget(100, 6)->findDropped($fixable, ['file.lineEnding'])); + Assert::true(Budget::isFoundation('indentation.chain')); + + // the decision whose files the others change least is left first, which leaves fewer than the costliest would + $overlapping = [ + 'blankLines.afterOpeningTag' => $files('a', 'b', 'c'), + 'spacing.cast' => $files('a', 'b'), + 'literals.quotes' => $files('d', 'e'), + ]; + Assert::same(['literals.quotes'], new Budget(100, 3)->findDropped($overlapping)); + }, +); diff --git a/tests/DressCode/Measuring/Measurement.phpt b/tests/DressCode/Measuring/Measurement.phpt new file mode 100644 index 0000000000..eed72ec83b --- /dev/null +++ b/tests/DressCode/Measuring/Measurement.phpt @@ -0,0 +1,119 @@ +value = $values->get('project.variable')->getText(); + } + + + public function getVisitedNodes(): array + { + return [VariableNode::class]; + } + + + public function enter(Node|Token $node, RuleContext $context): void + { + if (!$node instanceof VariableNode || !$node->name instanceof Token) { + return; + } elseif ($node->name->text === '$boom' && $this->value === 'b') { + throw new RuntimeException('boom'); + } elseif ($node->name->text !== '$' . $this->value) { + $context->report($node, "Not \$$this->value."); + } + } +} + + +test('the places of a decision of two values are what the two report together, less those in neither', function () { + // 6 places agree with a, 3 with b, 1 with neither: a reports 3 + 1, b 6 + 1, the profile of both 1 + $measurement = Measurement::fromReportedPlaces(['a' => 4, 'b' => 7], 'places', 1); + Assert::same(10, $measurement->opportunities); + Assert::same(['a' => 6, 'b' => 3], $measurement->agreeing); +}); + + +test('a place of a decision of three values is reported by the two it disagrees with', function () { + // 5 a, 3 b, 2 c, 2 in none: each value reports the places of the others and the 2 + $measurement = Measurement::fromReportedPlaces(['a' => 7, 'b' => 9, 'c' => 10], 'places', 2); + Assert::same(12, $measurement->opportunities); + Assert::same(['a' => 5, 'b' => 3, 'c' => 2], $measurement->agreeing); + Assert::same('a', $measurement->findCommonest()); +}); + + +test('of five values each place is reported four times, or five where it agrees with none', function () { + // 20 a, 4 b, none of c, d and e, no profile of all of them + $measurement = Measurement::fromReportedPlaces(['a' => 4, 'b' => 20, 'c' => 24, 'd' => 24, 'e' => 24], 'names'); + Assert::same(24, $measurement->opportunities); + Assert::same(['a' => 20, 'b' => 4, 'c' => 0, 'd' => 0, 'e' => 0], $measurement->agreeing); + Assert::same('a in 83%, b in 17% of 24 names', $measurement->describe()); +}); + + +test('what the code says of a decision is told by the share of its commonest value, the count of places and the tolerance', function () { + $classify = fn(array $reported, int $neither = 0, bool $tolerance = false) => Measurement::fromReportedPlaces( + $reported, 'places', $neither, + ) + ->classify($tolerance); + Assert::same(Classification::Settled, $classify(['a' => 3, 'b' => 9])); // a in 9 of 12 + Assert::same(Classification::SettledWeakly, $classify(['a' => 0, 'b' => 3])); // a in all 3 + Assert::same(Classification::Mixed, $classify(['a' => 1, 'b' => 3])); // a in 3 of 4, too few to settle + Assert::same(Classification::Mixed, $classify(['a' => 6, 'b' => 6])); // half and half + Assert::same(Classification::Tolerated, $classify(['a' => 6, 'b' => 6], tolerance: true)); + Assert::same(Classification::Mixed, $classify(['a' => 10, 'b' => 10], neither: 8, tolerance: true)); // 8 of 12 in none + Assert::same(Classification::Absent, $classify(['a' => 0, 'b' => 0])); +}); + + +test('a decision nothing reports under any value appears nowhere', function () { + $measurement = Measurement::fromReportedPlaces(['a' => 0, 'b' => 0, 'c' => 0], 'places'); + Assert::same(0, $measurement->opportunities); + Assert::null($measurement->findCommonest()); +}); + + +test('a file a rule fails in under one value counts for none of them, nor do its places', function () { + $root = createTempDir('measured'); + file_put_contents("$root/a.php", " ['a.php', 'b.php', 'boom.php'], 'rules' => [MeasuredRule::class]]; + $probes = new InProcessProbeRunner($root)->run([ + 'a' => [...$base, 'project' => ['variable' => 'a']], + 'b' => [...$base, 'project' => ['variable' => 'b']], + ]); + + $files = Measurement::fromFileProbes($probes, 'project.variable'); + Assert::same(['a' => 1, 'b' => 1], $files->agreeing); + Assert::same(2, $files->opportunities); + Assert::same('a in 50%, b in 50% of 2 files, 1 failing file left out', $files->describe()); + + $places = Measurement::fromPlaceProbes($probes, 'project.variable', 'variables'); + Assert::same(['a' => 1, 'b' => 1], $places->agreeing); + Assert::same(2, $places->opportunities); + Assert::same(1, $places->failed); +}); diff --git a/tests/DressCode/Measuring/MeasurementPlan.phpt b/tests/DressCode/Measuring/MeasurementPlan.phpt new file mode 100644 index 0000000000..e4cb0861c8 --- /dev/null +++ b/tests/DressCode/Measuring/MeasurementPlan.phpt @@ -0,0 +1,54 @@ + '', 'double' => '']), 'Quotes')); + Assert::same(['single' => 'single', 'double' => 'double'], $words->candidates); + Assert::null($words->any); + Assert::same(MeasuringMethod::Values, $words->method); + + $tolerant = MeasurementPlan::fromDecision(new Decision( + 'project.items', + new Words(['perLine' => '', 'fill' => ''], tolerance: true), + 'Items', + )); + Assert::same(['perLine', 'fill'], $tolerant->any); + + $shapes = MeasurementPlan::fromDecision(new Decision( + 'project.call', + new Shapes(['compact' => ['foo()', ''], 'spaced' => ['foo ()', '']]), + 'Call', + )); + Assert::same(['compact' => 'foo()', 'spaced' => 'foo ()'], $shapes->candidates); + + $required = MeasurementPlan::fromDecision(new Decision('project.visibility', new Words(['required' => 'always there']), 'Visibility')); + Assert::same(MeasuringMethod::OneSided, $required->method); + + // a count from 0 to 2 within its bounds, and what the standards give it + $count = new Decision('project.blank', new Count(0, 5), 'Blank lines'); + Assert::same([0 => 0, 1 => 1, 2 => 2, 4 => 4], MeasurementPlan::fromDecision($count, [4, 'keep', 1])->candidates); + Assert::same('0-4', MeasurementPlan::fromDecision($count, [4])->any); + Assert::same([1 => 1], MeasurementPlan::fromDecision(new Decision('project.one', new Count(1, 1, range: false), 'One'))->candidates); + + $names = MeasurementPlan::fromDecision(new Decision('project.list', new Names, 'List')); + Assert::same(MeasuringMethod::Policy, $names->method); + Assert::same([], $names->candidates); +}); + + +test('the width of a line is measured on a ladder of its parameter, the probes laying the requirement with each step', function () { + $plan = MeasurementPlan::fromDecision(new Decision('file.longLines', new Words(['forbidden' => 'never there']), 'Long lines')); + Assert::same(MeasuringMethod::Ladder, $plan->method); + Assert::same(MeasurementPlan::LineLengths, array_values($plan->candidates)); + Assert::same( + ['file' => ['longLines' => 'forbidden', 'lineLength' => 120]], + $plan->buildProbes([])['file.longLines=120'] ?? null, + ); +}); diff --git a/tests/DressCode/Measuring/NamespaceDeclarations.phpt b/tests/DressCode/Measuring/NamespaceDeclarations.phpt new file mode 100644 index 0000000000..5e6ac3a3d0 --- /dev/null +++ b/tests/DressCode/Measuring/NamespaceDeclarations.phpt @@ -0,0 +1,21 @@ +functions); + Assert::same(['src/gone.php', 'missing.php'], $declarations->unparsed); + }, +); diff --git a/tests/DressCode/Measuring/ProbeRunner.phpt b/tests/DressCode/Measuring/ProbeRunner.phpt new file mode 100644 index 0000000000..d363f174ae --- /dev/null +++ b/tests/DressCode/Measuring/ProbeRunner.phpt @@ -0,0 +1,99 @@ +=8.1"}}'); +file_put_contents("$root/src/a.php", " ['paths' => ['src/a.php', 'src/b.php', 'src/broken.php'], 'literals' => ['quotes' => 'single']], + 'double' => ['paths' => ['src/a.php', 'src/b.php'], 'literals' => ['quotes' => 'double']], +]; + + +test('a probe counts by decision what disagrees with its configuration, and what a fix would change', function () use ($root, $probes) { + $progress = []; + $results = new InProcessProbeRunner($root, function (int $done, int $all) use (&$progress) { + $progress[] = "$done/$all"; + })->run($probes); + Assert::same(['1/2', '2/2'], $progress); + Assert::same(['literals.quotes' => ['src/a.php' => 2]], $results['single']->places); + Assert::same(['literals.quotes' => ['src/a.php' => true]], $results['single']->fixable); + Assert::same(['literals.quotes'], $results['single']->findResidue()); + Assert::same(1, $results['single']->changedFiles); + Assert::same(['src/broken.php'], $results['single']->syntaxErrors); + Assert::same( + [1, 1], + [$results['double']->countFiles('literals.quotes'), array_sum($results['double']->places['literals.quotes'] ?? [])], + ); +}); + + +test('the probes run as processes of check count the same as in the process, the root being the project', function () use ($root, $probes) { + $binary = getPhpCliBinary(); + $progress = []; + $runner = new ProcessProbeRunner( + $root, + [$binary, __DIR__ . '/../../../bin/dresscode'], + 2, + function (int $done, int $all) use (&$progress) { + $progress[] = "$done/$all"; + }, + ); + $inProcess = new InProcessProbeRunner($root)->run($probes); + Assert::equal($inProcess, $runner->run($probes)); + // told at every look while the probes run, the last time when all are done + Assert::same(['0/2', '2/2'], [$progress[0], end($progress)]); + + Assert::exception( + fn() => $runner->run(['bad' => ['literals' => ['quotes' => 'curly']]]), + RuntimeException::class, + 'A check of the measuring ended with exit code 3: %a%', + ); +}); + + +test('a probe in which a rule fails in a file counts that file, while a probe that cannot run ends the measuring', function () use ($root) { + $script = createTempDir('probe-script') . '/check.php'; + $report = json_encode( + ['files' => [['path' => 'src/a.php', 'violations' => [], 'changed' => false, 'syntaxError' => null, 'failure' => 'boom']]], + ); + file_put_contents($script, "run(['a' => []])['a']->failed); + putenv('PROBE_EXIT=3'); + Assert::exception(fn() => $runner->run(['a' => []]), RuntimeException::class, '%a% exit code 3: %a%'); + } finally { + putenv('PROBE_EXIT'); + } +}); + + +test('a process of a probe starts no probes of its own, so the processes never grow beyond one level', function () use ($root, $probes) { + putenv('DRESSCODE_PROBE=1'); + try { + Assert::exception( + fn() => ProcessProbeRunner::create($root)->run($probes), + LogicException::class, + 'A probe of `dresscode init` starts no probes of its own.', + ); + } finally { + putenv('DRESSCODE_PROBE'); + } +}); + + +test('the probes are run by the script of this DressCode, whoever calls the runner', function () use ($root) { + $command = (fn() => $this->command)->call(ProcessProbeRunner::create($root)); + Assert::same(realpath(__DIR__ . '/../../../bin/dresscode'), realpath((string) end($command))); +}); diff --git a/tests/DressCode/Measuring/Scope.phpt b/tests/DressCode/Measuring/Scope.phpt new file mode 100644 index 0000000000..173dd9f08f --- /dev/null +++ b/tests/DressCode/Measuring/Scope.phpt @@ -0,0 +1,236 @@ + $files + */ +function createScopeProject(string $name, array $files): string +{ + $root = createTempDir($name); + foreach ($files as $path => $content) { + @mkdir(dirname("$root/$path"), recursive: true); // @ directory may already exist + file_put_contents("$root/$path", $content); + } + + return $root; +} + + +/** @param list $args */ +function runScopeGit(string $root, array $args): void +{ + exec('git -C ' . escapeshellarg($root) . ' ' . implode(' ', array_map(escapeshellarg(...), $args)) . ' 2>&1', $output, $exit); + if ($exit !== 0) { + Environment::skip('git does not run: ' . implode("\n", $output)); + } +} + + +$php = " $php, + 'src/Parser/Generated.php' => " " $php, + 'src/Data/one.php' => $php, + 'src/Data/two.php' => $php, + 'src/Lazy/Proxy.php' => $php, + 'tests/a.phpt' => $php, + 'tests/fixtures/style.php' => $php, + 'build/scripts/x.php' => $php, + 'build/tracked.php' => $php, + 'compile.php' => $php, + 'vendor/lib/a.php' => $php, + 'notes/readme.txt' => 'text', + '.gitattributes' => "src/Data/** linguist-generated\n", + '.gitignore' => "/build/scripts/\n/src/Lazy/\nlocal.php\n", + 'src/local.php' => $php, + ]); + runScopeGit($root, ['init', '-q']); + runScopeGit($root, ['add', '-A']); + runScopeGit($root, ['add', '-f', 'vendor']); + file_put_contents("$root/src/untracked.php", $php); // not measured, but said, as the first run takes it + + $scope = Scope::fromGit($root, ['php', 'phpt']); + Assert::notNull($scope); + Assert::same( + ['build/tracked.php', 'compile.php', 'src/A.php', 'src/Parser/Lexer.php', 'src/Parser/Messages.php', 'tests/a.phpt'], + $scope->files, + ); + Assert::same(['src/untracked.php'], $scope->untracked); + Assert::same([ + 'src/Data/one.php' => LeftOutReason::Generated, + 'src/Data/two.php' => LeftOutReason::Generated, + 'src/Parser/Generated.php' => LeftOutReason::Generated, + 'tests/fixtures/style.php' => LeftOutReason::TestData, + 'build/scripts' => LeftOutReason::Ignored, + 'src/Lazy' => LeftOutReason::Ignored, + 'src/local.php' => LeftOutReason::Ignored, + ], $scope->leftOut); + Assert::same(['build', 'compile.php', 'src', 'tests'], $scope->listPaths()); + // the root named as the path of a formatter holds every file + Assert::same($scope->files, Scope::fromGit($root, ['php', 'phpt'], ['.'])?->files); + Assert::same([ + 'build/scripts' => LeftOutReason::Ignored, + 'fixtures' => LeftOutReason::TestData, + 'src/Data' => LeftOutReason::Generated, + 'src/Lazy' => LeftOutReason::Ignored, + 'src/Parser/Generated.php' => LeftOutReason::Generated, + 'src/local.php' => LeftOutReason::Ignored, + ], $scope->listExcludePaths()); + + // a file that does not parse is left out once a check says so + $scope->leaveOut(['src/A.php', 'src/gone.php'], LeftOutReason::Unparsed); + Assert::same( + ['build/tracked.php', 'compile.php', 'src/Parser/Lexer.php', 'src/Parser/Messages.php', 'tests/a.phpt'], + $scope->files, + ); + Assert::same(LeftOutReason::Unparsed, $scope->leftOut['src/A.php']); + Assert::same(LeftOutReason::Unparsed, $scope->listExcludePaths()['src/A.php'] ?? null); + }, +); + + +test('a test in the format PHPT is no PHP, and an extension no file of the scope has needs no exclusion', function () use ($php) { + $phpt = "--TEST--\nA test\n--FILE--\n $php, 'tests/a.phpt' => $phpt, 'tests/b.phpt' => $phpt]); + runScopeGit($root, ['init', '-q']); + runScopeGit($root, ['add', '-A']); + $scope = Scope::fromGit($root, ['php', 'phpt']); + Assert::same(['src/a.php'], $scope?->files); + Assert::same(['php'], $scope->listExtensions()); + Assert::same(['src'], $scope->listPaths()); + Assert::same([], $scope->listExcludePaths()); + + // beside a .phpt that is PHP, the configuration takes the extension and has to exclude the test + file_put_contents("$root/tests/c.phpt", $php); + runScopeGit($root, ['add', '-A']); + $scope = Scope::fromGit($root, ['php', 'phpt']); + Assert::same(['php', 'phpt'], $scope?->listExtensions()); + Assert::same(['tests/a.phpt' => LeftOutReason::PhptTest, 'tests/b.phpt' => LeftOutReason::PhptTest], $scope->listExcludePaths()); +}); + + +test('a file the paths name one by one is excluded once it is left out, whatever the reason', function () use ($php) { + $root = createScopeProject('scope-named', ['only.php' => $php, 'src/a.php' => $php]); + foreach (LeftOutReason::cases() as $reason) { + $scope = Scope::fromPaths($root, ['only.php', 'src'], ['php']); + $scope->leaveOut(['only.php'], $reason); + Assert::same(['only.php', 'src'], $scope->listPaths()); + Assert::same(['only.php' => $reason], $scope->listExcludePaths(), $reason->name); + } +}); + + +test('a root that lies in no repository has no scope from git', function () use ($php) { + $root = createScopeProject('scope-no-git', ['src/a.php' => $php]); + Assert::null(Scope::fromGit($root, ['php'])); +}); + + +test('without git the scope is what the autoload names and the conventional directories beside it', function () use ($php) { + $composer = fn(array $autoload) => json_encode($autoload, JSON_THROW_ON_ERROR); + $paths = fn(string $root) => Scope::fromAutoload($root, ['php'])->listPaths(); + + // psr-4 with a prefix on several paths and one that is not there, a test suite only the convention knows + $root = createScopeProject('scope-psr4', [ + 'composer.json' => $composer([ + 'autoload' => ['psr-4' => ['App\\' => 'ModuleFront', 'App\\Api\\' => ['ModuleApi/', 'gone']]], + 'autoload-dev' => ['psr-4' => ['App\\Tests\\' => 'test/']], + ]), + 'ModuleFront/a.php' => $php, + 'ModuleApi/a.php' => $php, + 'test/a.php' => $php, + 'www.admin/index.php' => $php, + 'cron/a.php' => $php, + 'assets/a.php' => $php, + ]); + Assert::same(['ModuleApi', 'ModuleFront', 'cron', 'test', 'www.admin'], $paths($root)); + + // a classmap names directories and single files, as the files section does, and a file outside the directories is a path + $root = createScopeProject('scope-classmap', [ + 'composer.json' => $composer( + ['autoload' => ['classmap' => ['src/', 'src/helpers.php', 'legacy.php'], 'files' => ['bootstrap.php']]], + ), + 'src/a.php' => $php, + 'src/helpers.php' => $php, + 'legacy.php' => $php, + 'bootstrap.php' => $php, + 'tests/a.php' => $php, + ]); + Assert::same(['bootstrap.php', 'legacy.php', 'src', 'tests'], $paths($root)); + + // a path inside another one is already in the scope, whichever of the two named it + $root = createScopeProject('scope-nested', [ + 'composer.json' => $composer(['autoload' => ['psr-4' => ['App\\' => 'ModuleFront/', 'App\\Sub\\' => 'ModuleFront/Sub']]]), + 'ModuleFront/a.php' => $php, + 'ModuleFront/Sub/a.php' => $php, + ]); + Assert::same(['ModuleFront'], $paths($root)); + + $root = createScopeProject('scope-app', [ + 'composer.json' => $composer(['autoload' => ['psr-0' => ['' => 'app/Model']]]), + 'app/Model/a.php' => $php, + 'app/bootstrap.php' => $php, + ]); + Assert::same(['app'], $paths($root)); + + // a dot segment names the same directory as the path without it, and only resolved do the two compare as one + $root = createScopeProject('scope-dots', [ + 'composer.json' => $composer(['autoload' => ['psr-4' => ['App\\' => './src', 'App\\Api\\' => 'app/../ModuleApi']]]), + 'src/a.php' => $php, + 'ModuleApi/a.php' => $php, + ]); + Assert::same(['ModuleApi', 'src'], $paths($root)); + + // an autoload pointing at the root itself is the whole scope + $root = createScopeProject('scope-root', [ + 'composer.json' => $composer(['autoload' => ['psr-4' => ['App\\' => '']]]), + 'src/a.php' => $php, + ]); + Assert::same(['.'], $paths($root)); + + // the composer.json of a directory above names paths of another project, which are none of this scope + $root = createScopeProject('scope-above/package', [ + '../composer.json' => $composer(['autoload' => ['psr-4' => ['App\\' => 'src']]]), + '../src/a.php' => $php, // the src of the project above, which exists and is still none of this scope + 'src/a.php' => $php, + ]); + Assert::same(['src'], $paths($root)); + + // without a composer.json the convention is the whole answer, and the data of tests and a generated file are left out + $root = createScopeProject('scope-conventional', [ + 'src/a.php' => $php, + 'src/b.php' => " $php, + 'tests/expected/a.php' => $php, + 'storage/a.php' => $php, + ]); + $scope = Scope::fromAutoload($root, ['php']); + Assert::same(['src', 'tests'], $scope->listPaths()); + Assert::same(['src/a.php', 'tests/a.php'], $scope->files); + Assert::same(['expected' => LeftOutReason::TestData, 'src/b.php' => LeftOutReason::Generated], $scope->listExcludePaths()); +}); + + +test('the head of a file is looked at for the mark of a generator without a warning of the lexer', function () { + $file = createTempDir('scope-lexer') . '/a.php'; + file_put_contents($file, ' new ReflectionMethod(StandardChoice::class, 'choose')->invoke( + null, $residues, $hint, +); + + +test('the fewest files changed win, a close call going to what the formatter names, then to perCs', function () use ($choose) { + Assert::same(['symfony', StandardReason::Fewest, []], $choose(['symfony' => 9, 'psr12' => 21, 'perCs' => 36])); + Assert::same(['nette', StandardReason::Fewest, []], $choose(['symfony' => 20, 'nette' => 21, 'perCs' => 40], 'nette')); + Assert::same(['perCs', StandardReason::Fewest, ['symfony']], $choose(['symfony' => 20, 'perCs' => 21, 'nette' => 40])); + Assert::same(['nette', StandardReason::Fewest, ['symfony']], $choose(['nette' => 20, 'symfony' => 21, 'perCs' => 40])); +}); + + +test('psr12 yields to perCs, unless perCs departs half again as much or the formatter names psr12', function () use ($choose) { + Assert::same(['perCs', StandardReason::Successor, []], $choose(['psr12' => 9, 'perCs' => 11, 'symfony' => 24])); + Assert::same(['perCs', StandardReason::Successor, []], $choose(['psr12' => 20, 'perCs' => 28, 'symfony' => 33], 'perCs')); + Assert::same(['psr12', StandardReason::Fewest, []], $choose(['psr12' => 22, 'symfony' => 25, 'perCs' => 36])); + Assert::same(['psr12', StandardReason::Fewest, []], $choose(['psr12' => 29, 'symfony' => 34, 'perCs' => 40], 'psr12')); + Assert::same(['symfony', StandardReason::Fewest, []], $choose(['psr12' => 20, 'symfony' => 21, 'perCs' => 28], 'symfony')); +}); + + +test('laravel is checked only for a project that is or requires a package of Laravel, or that Laravel Pint formats', function () { + $root = createTempDir('laravel'); + file_put_contents("$root/composer.json", '{"name": "acme/eshop", "require": {"nette/application": "^3.2"}}'); + $standards = array_merge(...StandardChoice::listCandidates(ProjectFacts::detect($root), null)); + Assert::notContains('laravel', $standards); + Assert::contains('nette', $standards); + + file_put_contents("$root/composer.json", '{"name": "acme/shop", "require": {"laravel/framework": "^12.0"}}'); + Assert::contains('laravel', array_merge(...StandardChoice::listCandidates(ProjectFacts::detect($root), null))); + + file_put_contents("$root/composer.json", '{"name": "acme/eshop"}'); + file_put_contents("$root/pint.json", '{"preset": "empty"}'); + Assert::contains('laravel', array_merge(...StandardChoice::listCandidates(ProjectFacts::detect($root), null))); +}); + + +test('a standard a rule failed under is chosen only where every one failed, its residue measured short', function () { + $root = createTempDir('choice-failed'); + file_put_contents("$root/composer.json", '{"name": "acme/eshop"}'); + $runner = new class implements ProbeRunner { + public function run(array $probes): array + { + // perCs reports the fewest, as a rule failed in a file before it could report more + return array_map(fn(string $key) => new ProbeResult( + array_fill_keys($key === 'perCs' ? ['a'] : ['a', 'b', 'c'], ['src/a.php' => 1]), + [], + [], + 0, + [], + $key === 'perCs' ? ['src/b.php'] : [], + ), array_combine(array_keys($probes), array_keys($probes))); + } + }; + Assert::notSame(['perCs'], StandardChoice::measure(ProjectFacts::detect($root), null, $runner, [])->presets); +}); diff --git a/tests/DressCode/Plugin/plugin.phpt b/tests/DressCode/Plugin/plugin.phpt index bb2d049f1b..f1393af0c6 100644 --- a/tests/DressCode/Plugin/plugin.phpt +++ b/tests/DressCode/Plugin/plugin.phpt @@ -20,7 +20,13 @@ function runPlugin(array $args, ?string $cwd = null, ?Config $default = null): a { $out = fopen('php://memory', 'w+') ?: throw new RuntimeException; $err = fopen('php://memory', 'w+') ?: throw new RuntimeException; - $app = new Application($out, $err, cwd: $cwd ?? __DIR__ . '/fixtures/project', defaultConfig: $default); + $app = new Application( + $out, + $err, + cwd: $cwd ?? __DIR__ . '/fixtures/project', + script: __DIR__ . '/../../../bin/dresscode', + defaultConfig: $default, + ); $code = $app->run(['dresscode', ...$args]); rewind($out); rewind($err); diff --git a/tests/DressCode/Presets/fixtures/symfony/clean.code b/tests/DressCode/Presets/fixtures/symfony/clean.code new file mode 100644 index 0000000000..e1697240a3 --- /dev/null +++ b/tests/DressCode/Presets/fixtures/symfony/clean.code @@ -0,0 +1,61 @@ + + */ + private array $names = []; + + public function __construct( + private readonly Connection $connection, + private readonly ?LoggerInterface $logger = null, + ) { + } + + /** + * @param list $names + * + * @return list + */ + public function findByNames(array $names, int $limit = self::DEFAULT_LIMIT): array + { + $rows = $this->connection->fetchAll($names, $limit); + $labels = array_map(fn ($row) => $row->name, $rows); + $matrix = [ + 1, 2, 3, + 4, 5, 6, + ]; + + try { + return array_map(static function ($row) { + return new User($row->id, $row->name); + }, $rows); + } catch (\RuntimeException|\LogicException $e) { + $this->logger?->error($e->getMessage()); + + throw new RepositoryException($e->getMessage(), 0, $e); + } + } + + public function count(): int + { + if (0 === \count($this->names)) { + return 0; + } + + return $this->connection->count(); + } +} diff --git a/tests/DressCode/Presets/fixtures/symfony/per.code b/tests/DressCode/Presets/fixtures/symfony/per.code new file mode 100644 index 0000000000..4ce01c52c4 --- /dev/null +++ b/tests/DressCode/Presets/fixtures/symfony/per.code @@ -0,0 +1,38 @@ + $this->subject, + Priority::High => strtoupper($this->subject), + }; + } + + public function help(): string + { + return << "dresscode/$standard", CorePlugin::Standards)); $registry = new PluginRegistry; $resolver = new ConfigResolver($registry); @@ -40,17 +40,14 @@ test('a set decides nothing about the layout', function () use ($written) { test('a decision is made by a set or a standard, or written by the project on purpose', function () use ($written, $resolver) { // a decision outside every set and every standard is one a project writes itself, and the reason is here $onRequest = [ - 'blankLines.afterStatement', 'blankLines.beforeStatement', // where a statement stands apart is the shape of a body, which no standard prescribes - 'blankLines.belowComment.inlineVar', // the blank lines between the `@var` of a statement and the statement, which no standard sets + 'blankLines.afterStatement', // where a statement stands apart is the shape of a body, which no standard prescribes 'blankLines.bodyStart.switchCase', // the first statement of a case, which no standard sets apart or not 'braces.emptyBody.controlStructure', // an empty branch or loop, whose braces no standard writes 'classes.internalClass.final', // what a project does with its own internals - 'classes.protectedInFinalClass', // what a final class keeps open to its parent and its traits, which no standard decides 'classes.staticMethod', // how a class is built, which no set decides for it 'controlFlow.trailingIf', // the shape of a function body, which no standard prescribes 'controlFlow.elseifAfterReturn', // an `elseif` split into an `if` reads as another question, which a project chooses 'controlFlow.ifElseReturning', // a ternary for two branches is a shape of the code, no construct a newer PHP gave - 'casing.hexDigit', // the case of a hexadecimal digit, which no standard of the core settles 'naming.exceptionAffix', // how an exception is named, which projects settle each their own way 'mistakes.neverReturnType.anonymousFunction', // the signature of a closure usually belongs to the call it is passed to 'mistakes.readonlyProperty', // a guarantee the code takes on, which no older construct gave @@ -64,13 +61,14 @@ test('a decision is made by a set or a standard, or written by the project on pu // the `use` of a closure and a group of attributes, which no standard spreads 'multiline.items.closureUse', 'multiline.edges.closureUse', 'multiline.items.attribute', 'multiline.edges.attribute', 'phpdoc.types.nullable', 'phpdoc.types.unionOrder', 'types.unionOrder', // an order or a notation a project chooses - 'qualification.constant.otherNamespace', 'qualification.class.global', - 'qualification.staticInFinalClass', 'qualification.function.otherNamespace', // how far a name is written out is the project's + 'qualification.constant.otherNamespace', 'qualification.function.otherNamespace', // how far a name is written out is the project's 'qualification.function.currentNamespace', 'qualification.constant.currentNamespace', 'types.declaration.constant', // a typed constant, which the code before PHP 8.3 cannot have 'upgrading.classes.Override', // a guarantee the code takes on, which no older construct gave 'upgrading.phpdoc.readonly', // an annotation may promise what the code does not keep, which only the project knows 'upgrading.classes.SensitiveParameter', // what is sensitive the project knows best + // what the estimate of an upgrade counts of what it cannot see, nothing a project fixes + 'upgrading.unseen.classNameString', 'upgrading.unseen.dynamicAccess', // the maps: a set lets the upgrading files of the packages in, a project writes its own 'upgrading.api.attributeForAnnotation', 'upgrading.api.attributeForMember', 'upgrading.api.forbiddenClasses', 'upgrading.api.forbiddenConstants', 'upgrading.api.forbiddenFunctions', diff --git a/tests/DressCode/Presets/symfony.phpt b/tests/DressCode/Presets/symfony.phpt new file mode 100644 index 0000000000..bf71f44398 --- /dev/null +++ b/tests/DressCode/Presets/symfony.phpt @@ -0,0 +1,126 @@ +resolve(new Config(use: ['symfony']), Config::DefaultPhpVersion); +$rules = RuleBuilder::buildRules($resolved); +// which rules run, not in what order +$slugs = array_map(fn($rule) => ruleSlug($rule), $rules); +sort($slugs); +$expected = [ + 'delimiterSpacing', + 'arraySyntax', + 'trailingComma', + 'classHeadSpacing', + 'nameCasing', + 'memberOrder', + 'privateForProtected', + 'noGroupedDeclarations', + 'uselessNullInitialization', + 'visibilityRequired', + 'modifierOrder', + 'commentSpacing', + 'noEmptyComments', + 'noHashComments', + 'noBracelessBodies', + 'elseifNotation', + 'fallThroughComment', + 'multilineCondition', + 'noAlternativeSyntax', + 'noContinueInSwitch', + 'noEmptyStatements', + 'switchCaseNotation', + 'switchCaseSpacing', + 'uselessBraces', + 'uselessParenthesesAfterConstruct', + 'uselessElse', + 'uselessReturn', + 'binaryOperatorSpacing', + 'castCanonicalType', + 'operandSpacing', + 'emptyArgumentParentheses', + 'increment', + 'multilineBinaryOperator', + 'noDoubleNegations', + 'notEqualsNotation', + 'memberAccessSpacing', + 'ternaryOperatorSpacing', + 'yoda', + 'declareSpacing', + 'finalLineEndings', + 'openingTagNotation', + 'lineEnding', + 'noBom', + 'noClosingTag', + 'noTrailingWhitespace', + 'strictTypesRequired', + 'arrowFunctionForClosure', + 'functionNameSpacing', + 'methodCasing', + 'namedArgumentSpacing', + 'noDollarBraceInterpolations', + 'builtinCasing', + 'noBacktickOperators', + 'stringQuotes', + 'importNotation', + 'declaredNameCasing', + 'importOrder', + 'globalNameQualification', + 'optimizedCallNotation', + 'uselessLeadingBackslash', + 'noUnusedImports', + 'uselessAlias', + 'phpdocBlankLines', + 'phpdocTypeNotation', + 'nullableTypeForDefaultNull', + 'typeDeclarationSpacing', + 'typeNotation', + 'attributePosition', + 'attributeSpacing', + 'statementBlankLines', + 'memberBlankLines', + 'blockBlankLines', + 'bracesPosition', + 'commaSpacing', + 'constructSpacing', + 'indentation', + 'semicolonSpacing', + 'noStatementsSharingLine', +]; +sort($expected); +Assert::same($expected, $slugs); +Assert::same([' ', null], [$resolved->indent, $resolved->lineEnding]); + +$style = $resolved->createStyle(); +$processor = new PhpFileProcessor( + $rules, + $resolved->createAnalyses($style), + Config::DefaultPhpVersion, + $style, + policy: new ReportPolicy($registry->expandSuppressedName(...)), +); + +foreach (glob(__DIR__ . '/fixtures/symfony/*.code') ?: [] as $file) { + $code = (string) file_get_contents($file); + $target = (string) preg_replace('~\.code$~', '.expected', $file); + $expected = is_file($target) ? (string) file_get_contents($target) : $code; + $result = $processor->process(basename($file), $code); + Assert::null($result->syntaxError, basename($file)); + Assert::null($result->failure, basename($file)); + Assert::same($expected, $result->output, basename($file)); + Assert::same($expected === $code, !$result->violations, basename($file)); +} diff --git a/tests/DressCode/Reporters/reporters.phpt b/tests/DressCode/Reporters/reporters.phpt index 083ab9635d..6eee1a1dd0 100644 --- a/tests/DressCode/Reporters/reporters.phpt +++ b/tests/DressCode/Reporters/reporters.phpt @@ -1,5 +1,6 @@ .', 2, null, Severity::Warning, fingerprint: 'f2', derivedFrom: 'f1'), new Violation('test/rename', 'Rename $c.', 3, 1, Severity::Error, fingerprint: 'f3', derivedFrom: 'f1'), ], ['Rule test/x is faulty: it changed the file without reporting a violation.'], remaining: [ @@ -151,7 +159,7 @@ test('console: the refused risky fixes are counted by their risk, with the advic TypeAnalysisStatus::Available, false, '2 risky fixes wait, the type is unknown: set `typeAnalysis: phpstan`.', - 'list what the namespaces declare under `namespaces`, or set `nameResolution: certain` if they declare nothing', + 'run `dresscode init`, which lists what the namespaces declare, or set `nameResolution: certain` if they declare nothing', ], [ TypeAnalysisStatus::Enabled, @@ -163,7 +171,7 @@ test('console: the refused risky fixes are counted by their risk, with the advic TypeAnalysisStatus::Unavailable, false, '2 risky fixes wait, the type is unknown: check them with `fix --ask-risky`.', - 'list what the namespaces declare under `namespaces`, or set `nameResolution: certain` if they declare nothing', + 'run `dresscode init`, which lists what the namespaces declare, or set `nameResolution: certain` if they declare nothing', ], ] as [$typeAnalysis, $listed, $advice, $names]) { $stream = memory(); @@ -529,7 +537,13 @@ test('json', function () { "refused": false, "because": null, "fingerprint": "f1", - "derivedFrom": null + "derivedFrom": null, + "source": { + "package": "acme/upgrade-lib", + "library": "acme/lib", + "since": "3.0", + "from": "2.4" + } }, { "decision": "test/report", @@ -546,7 +560,8 @@ test('json', function () { "refused": false, "because": null, "fingerprint": "f2", - "derivedFrom": "f1" + "derivedFrom": "f1", + "source": null }, { "decision": "test/rename", @@ -563,7 +578,8 @@ test('json', function () { "refused": false, "because": null, "fingerprint": "f3", - "derivedFrom": "f1" + "derivedFrom": "f1", + "source": null } ], "remaining": [ @@ -582,7 +598,8 @@ test('json', function () { "refused": false, "because": null, "fingerprint": "f2", - "derivedFrom": null + "derivedFrom": null, + "source": null } ], "warnings": [ diff --git a/tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/basic.code b/tests/DressCode/Rules/fixtures/invokeCallNotation/basic.code similarity index 100% rename from tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/basic.code rename to tests/DressCode/Rules/fixtures/invokeCallNotation/basic.code diff --git a/tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/basic.expected b/tests/DressCode/Rules/fixtures/invokeCallNotation/basic.expected similarity index 100% rename from tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/basic.expected rename to tests/DressCode/Rules/fixtures/invokeCallNotation/basic.expected diff --git a/tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/basic.violations b/tests/DressCode/Rules/fixtures/invokeCallNotation/basic.violations similarity index 100% rename from tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/basic.violations rename to tests/DressCode/Rules/fixtures/invokeCallNotation/basic.violations diff --git a/tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/partial-application.code b/tests/DressCode/Rules/fixtures/invokeCallNotation/partial-application.code similarity index 100% rename from tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/partial-application.code rename to tests/DressCode/Rules/fixtures/invokeCallNotation/partial-application.code diff --git a/tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/partial-application.expected b/tests/DressCode/Rules/fixtures/invokeCallNotation/partial-application.expected similarity index 100% rename from tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/partial-application.expected rename to tests/DressCode/Rules/fixtures/invokeCallNotation/partial-application.expected diff --git a/tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/partial-application.violations b/tests/DressCode/Rules/fixtures/invokeCallNotation/partial-application.violations similarity index 100% rename from tests/DressCode/Rules/fixtures/noExplicitInvokeCalls/partial-application.violations rename to tests/DressCode/Rules/fixtures/invokeCallNotation/partial-application.violations diff --git a/tests/DressCode/Rules/fixtures/invokeCallNotation/required.code b/tests/DressCode/Rules/fixtures/invokeCallNotation/required.code new file mode 100644 index 0000000000..8931882ebe --- /dev/null +++ b/tests/DressCode/Rules/fixtures/invokeCallNotation/required.code @@ -0,0 +1,49 @@ +h)(1); + $c = (new Handler)(2); + $d = $registry->find()(3); + $e = Registry::make()(4); + $i = ($registry->handler)(5); + $k = $either(6); + $l = $f($g(1), 2); + $m = $f( + 1, + 2, // two + ); + $n = $f(...); + $o = $mixed(1); + $p = $closure(1); + $q = $callable(1); + $r = $unknown(1); + $s = $name(1); + $t = $plain(1); + $u = $nullable(1); + $v = $f /* c */ (1); + $w = ( /* c */ $this->h)(1); + $x = $f(/* c */); + $y = $f->__invoke(1); + $z = strlen('x'); + } +} diff --git a/tests/DressCode/Rules/fixtures/invokeCallNotation/required.expected b/tests/DressCode/Rules/fixtures/invokeCallNotation/required.expected new file mode 100644 index 0000000000..153f88e23b --- /dev/null +++ b/tests/DressCode/Rules/fixtures/invokeCallNotation/required.expected @@ -0,0 +1,49 @@ +__invoke($x, $y); + $b = $this->h->__invoke(1); + $c = (new Handler)->__invoke(2); + $d = $registry->find()->__invoke(3); + $e = Registry::make()->__invoke(4); + $i = $registry->handler->__invoke(5); + $k = $either->__invoke(6); + $l = $f->__invoke($g->__invoke(1), 2); + $m = $f->__invoke( + 1, + 2, // two + ); + $n = $f(...); + $o = $mixed(1); + $p = $closure(1); + $q = $callable(1); + $r = $unknown(1); + $s = $name(1); + $t = $plain(1); + $u = $nullable(1); + $v = $f /* c */ (1); + $w = ( /* c */ $this->h)(1); + $x = $f->__invoke(/* c */); + $y = $f->__invoke(1); + $z = strlen('x'); + } +} diff --git a/tests/DressCode/Rules/fixtures/invokeCallNotation/required.violations b/tests/DressCode/Rules/fixtures/invokeCallNotation/required.violations new file mode 100644 index 0000000000..5d427a2671 --- /dev/null +++ b/tests/DressCode/Rules/fixtures/invokeCallNotation/required.violations @@ -0,0 +1,10 @@ +23: The invokable object must be called through `__invoke()`. +24: The invokable object must be called through `__invoke()`. +25: The invokable object must be called through `__invoke()`. +26: The invokable object must be called through `__invoke()`. +27: The invokable object must be called through `__invoke()`. +28: The invokable object must be called through `__invoke()`. +29: The invokable object must be called through `__invoke()`. +30: The invokable object must be called through `__invoke()`. +31: The invokable object must be called through `__invoke()`. +45: The invokable object must be called through `__invoke()`. diff --git a/tests/DressCode/Rules/fixtures/invokeCallNotation/stubs/handlers.php b/tests/DressCode/Rules/fixtures/invokeCallNotation/stubs/handlers.php new file mode 100644 index 0000000000..7cbdccbea5 --- /dev/null +++ b/tests/DressCode/Rules/fixtures/invokeCallNotation/stubs/handlers.php @@ -0,0 +1,47 @@ +$method(); + $object->{$method . 'Async'}(); + $object->send(); + echo $object->$method; + echo $object->name; + $class::create(); + Factory::$method(); + Factory::create(); + new $class; + new Factory; + new class {}; + $callback(); + call_user_func($callback); + call_user_func('App\helper'); + \call_user_func_array([$object, $method], []); + helper(); +} diff --git a/tests/DressCode/Rules/fixtures/unseenReferences/dynamic-access.violations b/tests/DressCode/Rules/fixtures/unseenReferences/dynamic-access.violations new file mode 100644 index 0000000000..a14d5df4a5 --- /dev/null +++ b/tests/DressCode/Rules/fixtures/unseenReferences/dynamic-access.violations @@ -0,0 +1,9 @@ +8: A method called by a name the code computes, which no map of upgrading data sees. +9: A method called by a name the code computes, which no map of upgrading data sees. +11: A property read by a name the code computes, which no map of upgrading data sees. +13: A static method called by a name the code computes, which no map of upgrading data sees. +14: A static method called by a name the code computes, which no map of upgrading data sees. +16: A class instantiated by a name the code computes, which no map of upgrading data sees. +19: A function called by a name the code computes, which no map of upgrading data sees. +20: A function called by a name the code computes, which no map of upgrading data sees. +22: A function called by a name the code computes, which no map of upgrading data sees. diff --git a/tests/DressCode/Rules/fuzz-whitespace.phpt b/tests/DressCode/Rules/fuzz-whitespace.phpt index d923da6777..cff4690d23 100644 --- a/tests/DressCode/Rules/fuzz-whitespace.phpt +++ b/tests/DressCode/Rules/fuzz-whitespace.phpt @@ -416,7 +416,10 @@ function findFiles(string $dir): array $files = []; $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)); foreach ($iterator as $file) { - if (preg_match('~\.(php|phpt|inc)$~', $file->getFilename())) { + if ( + preg_match('~\.(php|phpt|inc)$~', $file->getFilename()) + && !preg_match('~(dresscode:ignoreFile|phpcs:ignoreFile)~', (string) file_get_contents($file->getPathname())) // silenced, it measures nothing + ) { $files[] = str_replace('\\', '/', $file->getPathname()); } } diff --git a/tests/DressCode/Rules/naming.phpt b/tests/DressCode/Rules/naming.phpt index ed30556c21..f96f7ae892 100644 --- a/tests/DressCode/Rules/naming.phpt +++ b/tests/DressCode/Rules/naming.phpt @@ -99,6 +99,7 @@ test('every name is built in the shape of its kind', function () use ($rules) { 'lineLength', // the example of a decision: the noun of the construct, the width its value 'redundantArguments', // says the state of the arguments it drops, which `hygiene.defaultArgument` names 'phpdocAboveAttributes', // says the state, in the words of the two constructs + 'unseenReferences', // names what it reports, the references an upgrade cannot see, which it never fixes ]; $outside = []; diff --git a/tests/DressCode/Rules/robustness.provider.php b/tests/DressCode/Rules/robustness.provider.php index 14b20811f4..422c0fdc1a 100644 --- a/tests/DressCode/Rules/robustness.provider.php +++ b/tests/DressCode/Rules/robustness.provider.php @@ -8,4 +8,5 @@ 'psr12' => ['presets' => ['psr12']], 'perCs' => ['presets' => ['perCs', 'modernizations', 'cleanup']], 'nette' => ['presets' => ['nette', 'modernizations', 'deprecations']], + 'symfony' => ['presets' => ['symfony', 'correctness']], ]; diff --git a/tests/DressCode/Testing/UpgradingTester.phpt b/tests/DressCode/Testing/UpgradingTester.phpt index 796cc05159..1266537d61 100644 --- a/tests/DressCode/Testing/UpgradingTester.phpt +++ b/tests/DressCode/Testing/UpgradingTester.phpt @@ -244,16 +244,43 @@ test('a key under extra.dresscode that DressCode does not read is a problem', fu $file = "package: acme/lib\n\nsince 3.0:\n\treplacedClasses:\n\t\tAcme\\Lib\\IControl: Acme\\Lib\\Control\n"; Assert::same( [], - check($file, '{"name": "acme/rules", "extra": {"dresscode": {"upgrading-dir": "upgrading", "plugin": "Acme\\\\Rules"}}}'), + check( + $file, + '{"name": "acme/rules", "extra": {"dresscode": {"upgrading-dir": "upgrading", "packages": ["acme/lib"], "plugin": "Acme\\\\Rules"}}}', + ), ); Assert::same([], check($file, '{"name": "acme/rules", "extra": {"branch-alias": {}}}')); Assert::same( - ['Package `acme/rules`: `extra.dresscode` in its `composer.json` holds the key `upgrades`, which DressCode does not know; it reads `upgrading-dir`, `plugin`.'], + ['Package `acme/rules`: `extra.dresscode` in its `composer.json` holds the key `upgrades`, which DressCode does not know; it reads `upgrading-dir`, `packages`, `plugin`.'], check($file, '{"name": "acme/rules", "extra": {"dresscode": {"upgrading-dir": "upgrading", "upgrades": []}}}'), ); }); +test('the packages a package lists are exactly those its upgrading files are about', function () { + $root = createTempDir('upgrading-packages'); + FileSystem::write("$root/upgrading/lib.neon", "package: acme/lib\n"); + FileSystem::write("$root/upgrading/tools.neon", "package: acme/tools\n"); + $problems = function (string $packages) use ($root): array { + FileSystem::write( + "$root/composer.json", + '{"name": "acme/rules", "extra": {"dresscode": {"upgrading-dir": "upgrading", "packages": ' . $packages . '}}}', + ); + return UpgradingTester::collectPackageProblems($root); + }; + + Assert::same([], $problems('["acme/lib", "acme/tools"]')); + Assert::same([], $problems('["acme/"]')); + Assert::same( + [ + 'Package `acme/rules`: `extra.dresscode.packages` in its `composer.json` does not list `acme/tools`, which `upgrading/tools.neon` is about.', + 'Package `acme/rules`: `extra.dresscode.packages` in its `composer.json` lists `acme/gone`, which no upgrading file is about.', + ], + $problems('["acme/lib", "acme/gone"]'), + ); +}); + + test('a forbidden map of a package must give every entry a sentence', function () { $problems = check(<<<'XX' package: acme/lib diff --git a/tests/DressCode/Upgrade/Composer.phpt b/tests/DressCode/Upgrade/Composer.phpt new file mode 100644 index 0000000000..9ba98b4aa9 --- /dev/null +++ b/tests/DressCode/Upgrade/Composer.phpt @@ -0,0 +1,122 @@ += 8.2) + latte/latte v3.1.6 requires php (8.2 - 8.5) + nette/application v3.3.0 requires php (8.3 - 8.5) + acme/legacy v1.0.0 conflicts php (>=8.4) + Not finding what you were looking for? Try calling `composer require "php:8.6" --dry-run` to get another view on the problem. + XX; + + $blockers = Composer::parseBlockers(Target::php('8.6'), $output, 'acme/shop'); + Assert::equal(Target::php('8.6'), $blockers->target); + Assert::equal( + [ + new Blocker('latte/latte', 'v3.1.6', '8.2 - 8.5'), + new Blocker('nette/application', 'v3.3.0', '8.3 - 8.5'), + new Blocker('acme/legacy', 'v1.0.0', 'conflicts with php >=8.4'), + ], + $blockers->packages, + ); +}); + + +test('the target itself refusing the PHP Composer runs on refuses no target', function () { + $output = <<<'XX' + acme/shop dev-master requires acme/mail (^3.2) + acme/mail v4.0.0 requires php (>=8.0 <8.3 but 8.5.12 is installed) + XX; + + Assert::same([], Composer::parseBlockers(new Target('acme/mail', '4.0'), $output, 'acme/shop')->packages); +}); + + +test('the operations of an update are read once, though Composer lists them for the lock and for the installation', function () { + $output = <<<'XX' + ./composer.json has been updated + Lock file operations: 0 installs, 1 update, 0 removals + - Upgrading nette/utils (v3.2.10 => v4.0.11) + Installing dependencies from lock file (including require-dev) + Package operations: 0 installs, 1 update, 0 removals + - Upgrading nette/utils (v3.2.10 => v4.0.11) + XX; + + Assert::same(['- Upgrading nette/utils (v3.2.10 => v4.0.11)'], Composer::parseOperations($output)); +}); + + +test('the moves of an update are read from its upgrades and downgrades, not from what it installs or removes', function () { + $output = <<<'XX' + Lock file operations: 1 install, 2 updates, 1 removal + - Removing acme/old (v1.0.0) + - Upgrading nette/utils (v3.2.10 => v4.0.11) + - Downgrading acme/mail (v2.1.0 => v2.0.3) + - Installing acme/new (v1.2.0) + XX; + + Assert::equal( + [new Move('nette/utils', 'v3.2.10', 'v4.0.11'), new Move('acme/mail', 'v2.1.0', 'v2.0.3')], + Composer::parseMoves(Composer::parseOperations($output)), + ); +}); + + +test('a Composer that fails says why, with the last line it wrote', function () { + putenv('COMPOSER_BINARY=dresscode-no-such-composer'); + try { + $root = createTempDir('composer-failed'); + file_put_contents("$root/composer.json", '{"require": {"acme/mail": "^3.0"}}'); + $composer = new Composer($root); + Assert::exception( + fn() => $composer->previewRequire(['acme/mail' => '^4.0']), + ComposerException::class, + 'Composer failed: %a%', + ); + Assert::match('Composer failed: %a%', (string) $composer->findMoves(['acme/mail' => '^4.0'])->unknown); + } finally { + putenv('COMPOSER_BINARY'); + } +}); + + +test('the dry run of a raise is of a copy of composer.json with the platform raised, which it leaves nowhere', function () { + $root = createTempDir('composer-preview'); + file_put_contents("$root/composer.json", '{"require": {"php": "^8.0"}}'); + // a Composer that tells the arguments and the platform of the file it is given + file_put_contents("$root/composer.php", <<<'XX' + ' . ($data['config']['platform']['php'] ?? 'none') . ")\n"; + XX); + putenv("COMPOSER_BINARY=$root/composer.php"); + try { + $composer = new Composer($root); + Assert::same(['- Upgrading acme/args (update --lock --dry-run => 8.4)'], $composer->previewRequire([], platform: '8.4')); + Assert::same(['- Upgrading acme/args (require php:^8.4 -W => 8.4)'], $composer->previewRequire(['php' => '^8.4'], platform: '8.4')); + Assert::same(['composer.json', 'composer.php'], array_values(array_diff(scandir($root), ['.', '..']))); + } finally { + putenv('COMPOSER_BINARY'); + } +}); + + +test('nothing refuses a version where why-not says no installed package depends on it otherwise', function () { + $output = 'There is no installed package depending on "php" in versions not matching 8.5'; + $blockers = Composer::parseBlockers(Target::php('8.5'), $output, null); + Assert::same([], $blockers->packages); + Assert::null($blockers->unknown); +}); + + +test('an output that is no answer of why-not leaves what refuses the version unknown', function () { + $blockers = Composer::parseBlockers(Target::php('8.5'), "Could not open input file: composer.phar\n", null); + Assert::same([], $blockers->packages); + Assert::same('Composer gave no answer: Could not open input file: composer.phar', $blockers->unknown); +}); diff --git a/tests/DressCode/Upgrade/Estimator.phpt b/tests/DressCode/Upgrade/Estimator.phpt new file mode 100644 index 0000000000..3fb511f136 --- /dev/null +++ b/tests/DressCode/Upgrade/Estimator.phpt @@ -0,0 +1,156 @@ + every topic as `name set phase constraint files: automatic types names manual` */ +function describeTopics(Estimate $estimate): array +{ + return array_map( + fn(Topic $topic) => "$topic->name $topic->set {$topic->phase->value} " . ($topic->constraint ?? '-') + . " $topic->files: " . implode(' ', $topic->places), + $estimate->topics, + ); +} + + +test('the places are told by topic, by phase and by what the run can do about them, the jump first', function () { + $root = createEstimatedProject('estimate'); + file_put_contents( + "$root/src/Dynamic.php", + "\$method();\n}\n", + ); + file_put_contents("$root/src/Generated.php", "estimate([Target::php('8.4')]); + + // curl_close() does nothing since 8.0 already, strftime() has no replacement, `0o` needs 8.1, `?int` works on 8.0 + Assert::same([ + 'php deprecations jump ^8.4 1: 0 0 0 1', + 'upgrading.syntax.octalPrefix modernizations jump ^8.4 1: 1 0 0 0', + 'php deprecations bridge ^8.0 1: 1 0 0 0', + 'upgrading.functions.substringFunctions modernizations bridge ^8.0 1: 1 0 0 0', + 'upgrading.php.implicitNullable deprecations bridge ^8.0 1: 1 0 0 0', + ], describeTopics($estimate)); + Assert::same(2, $estimate->files); + // what the upgrade cannot see is counted apart from the topics, and a generated file is left out + Assert::equal(new BlindSpots(dynamicAccesses: 1, otherFiles: ['latte' => 1], generatedFiles: 1), $estimate->blindSpots); + Assert::true($estimate->namesMeasured); + Assert::false($estimate->types); + Assert::same(['php' => '8.6'], $estimate->knownVersions); + // without a lock nothing is asked of Composer + Assert::same([], $estimate->blockers); +}); + + +test('where the configuration says the names are uncertain, a fix of a call of a function waits for them', function () { + $root = createEstimatedProject('estimate-uncertain'); + $config = new Config(paths: ['src'], nameResolution: 'uncertain'); + $estimate = new Estimator(new Project($config, $root))->estimate([Target::php('8.4')]); + + Assert::false($estimate->namesMeasured); + Assert::contains('php deprecations bridge ^8.0 1: 0 0 1 0', describeTopics($estimate)); + Assert::contains('upgrading.functions.substringFunctions modernizations bridge ^8.0 1: 0 0 1 0', describeTopics($estimate)); +}); + + +test('without a target the rules of a newer PHP stay off, what it retires is reported, and the project stays as it was', function () { + $root = createEstimatedProject('estimate-today'); + $before = hash_file('xxh128', "$root/src/Code.php"); + $estimate = new Estimator(new Project(new Config(paths: ['src']), $root))->estimate([]); + + // with no target, the jump has no constraint to need + Assert::same( + ['php -'], + array_map(fn(Topic $topic) => "$topic->name " . ($topic->constraint ?? '-'), $estimate->findTopics(Phase::Jump)), + ); + Assert::same($before, hash_file('xxh128', "$root/src/Code.php")); + Assert::notContains('Upgrading to PHP 8.0 is not covered; fixes of PHP 8 constructs stay off for 7.x.', $estimate->warnings); + + // a project on PHP 7 is told that its move to PHP 8 is no upgrade + file_put_contents("$root/composer.json", '{"require": {"php": "^7.4"}}'); + $estimate = new Estimator(new Project(new Config(paths: ['src']), $root))->estimate([]); + Assert::contains('Upgrading to PHP 8.0 is not covered; fixes of PHP 8 constructs stay off for 7.x.', $estimate->warnings); +}); + + + +test('the targets proposed are PHP and every library whose data know a newer version than the project allows', function () { + $root = createEstimatedProject('estimate-targets'); + $estimator = new Estimator(new Project(new Config(paths: ['src']), $root)); + + Assert::equal([Target::php('8.6')], $estimator->proposeTargets()); + Assert::equal([Target::php('8.6')], $estimator->proposeTargets(phpOnly: true)); + + // a project on PHP 7 is offered no move to PHP 8 + file_put_contents("$root/composer.json", '{"require": {"php": "^7.4"}}'); + Assert::same([], $estimator->proposeTargets()); +}); + + +test('a decision only the data of the target version turn on belongs to the jump, as nothing runs it today', function () { + require_once __DIR__ . '/../Console/fixtures/upgrade-plugin.php'; + $root = createTempDir('estimate-data-jump'); + mkdir("$root/src"); + file_put_contents("$root/composer.json", '{"require": {"php": ">=8.2", "acme/upgrade": "^1.0"}}'); + file_put_contents("$root/src/a.php", " [[ + 'name' => 'acme/upgrade', + 'version' => '1.0.0', + 'version_normalized' => '1.0.0.0', + 'install-path' => '../acme/upgrade', + 'extra' => ['dresscode' => ['plugin' => 'UpgradePlugin', 'upgrading-dir' => 'upgrading']], + ]]], JSON_THROW_ON_ERROR)); + + $estimate = new Estimator(new Project(new Config(paths: ['src']), $root))->estimate([new Target('acme/upgrade', '2.0')]); + // the rule does not say which entry it reports, and the map is filled by the data of that library alone + Assert::same(['acme/upgrade deprecations jump ^2.0 1: 0 0 0 1'], describeTopics($estimate)); +}); + + +test('the check a worker repeats is read back from its file, the scope of a project without a configuration file with it', function () { + $file = createTempDir('estimate-run') . '/run.json'; + $run = new UpgradeCheck( + new Config(use: ['deprecations']), + ['php'], + ['deprecations'], + new Config(paths: ['src'], excludePaths: ['src/Gen']), + ); + $run->write($file); + Assert::equal($run->toArray(), UpgradeCheck::read($file)->toArray()); + Assert::same(['src'], UpgradeCheck::read($file)->scope?->paths); +}); diff --git a/tests/DressCode/Upgrade/Git.phpt b/tests/DressCode/Upgrade/Git.phpt new file mode 100644 index 0000000000..583a89fadc --- /dev/null +++ b/tests/DressCode/Upgrade/Git.phpt @@ -0,0 +1,42 @@ + str_replace("\r", '', ProcessRunner::run(['git', ...array_values($args)], $repository, 60)[1]); + $git('init', '--quiet'); + $git('add', '--all'); + $git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'start'); + file_put_contents("$repository/pkg/src/A.php", "after\r\n"); + file_put_contents("$repository/pkg/src/B.php", 'mine'); + file_put_contents("$repository/composer.json", 'after'); + + putenv('GIT_AUTHOR_NAME=Test'); + putenv('GIT_COMMITTER_NAME=Test'); + putenv('GIT_AUTHOR_EMAIL=test@example.com'); + putenv('GIT_COMMITTER_EMAIL=test@example.com'); + $project = new Git("$repository/pkg"); + Assert::same(['src/A.php', '../composer.json'], $project->listUnignored(['src/A.php', '../composer.json', 'src/C.php'])); + $project->commit(['src/A.php' => "after\r\n", '../composer.json' => 'after'], 'subject', 'body'); + + Assert::same(".gitattributes\ncomposer.json\npkg/src/A.php\npkg/src/B.php\n", $git('ls-tree', '-r', '--name-only', 'HEAD')); + // the text is cleaned by the attributes of its path in the repository + Assert::same(["after\n", 'after', 'before'], [ + $git('show', 'HEAD:pkg/src/A.php'), + $git('show', 'HEAD:composer.json'), + $git('show', 'HEAD:pkg/src/B.php'), + ]); + // the index takes the files committed as they are, and the change of the other one stays the user's own + Assert::same(" M pkg/src/B.php\n", $git('status', '--porcelain')); +}); diff --git a/tests/DressCode/Upgrade/Move.phpt b/tests/DressCode/Upgrade/Move.phpt new file mode 100644 index 0000000000..764c4513aa --- /dev/null +++ b/tests/DressCode/Upgrade/Move.phpt @@ -0,0 +1,28 @@ +isMajor()); + Assert::true(new Move('acme/mail', '4.0.0', '3.9.0')->isMajor()); + Assert::true(new Move('acme/beta', '0.3.1', '0.4.0')->isMajor()); + Assert::false(new Move('nette/utils', 'v4.0.1', 'v4.1.0')->isMajor()); + Assert::false(new Move('acme/beta', '0.3.1', '0.3.7')->isMajor()); +}); + + +test('the data know a move when they reach the minor version it moves to, its major version alone being less', function () { + $known = ['nette/utils' => '4.1', 'acme/beta' => '0.3']; + Assert::true(new Move('nette/utils', 'v3.2.10', 'v4.0.11')->isKnown($known)); + Assert::true(new Move('nette/utils', 'v3.2.10', 'v4.1.5')->isKnown($known)); + Assert::false(new Move('nette/utils', 'v4.0.1', 'v4.9.0')->isKnown($known)); + Assert::true(new Move('nette/utils', 'v4.0.1', 'v4.9.0')->isSeriesKnown($known)); + Assert::false(new Move('nette/utils', 'v4.1.0', 'v5.0.0')->isKnown($known)); + Assert::false(new Move('nette/utils', 'v4.1.0', 'v5.0.0')->isSeriesKnown($known)); + Assert::false(new Move('acme/beta', '0.3.1', '0.4.0')->isSeriesKnown($known)); + Assert::false(new Move('acme/mail', 'v2.0.0', 'v3.0.0')->isSeriesKnown($known)); +}); diff --git a/tests/DressCode/Upgrade/Plan.phpt b/tests/DressCode/Upgrade/Plan.phpt new file mode 100644 index 0000000000..be89d8727f --- /dev/null +++ b/tests/DressCode/Upgrade/Plan.phpt @@ -0,0 +1,268 @@ + $blockers */ +function createPlanEstimate(bool $library = false, ?array $blockers = null): Estimate +{ + $topic = fn(string $name, string $set, Phase $phase) => new Topic($name, $set, $phase, 1, ['automatic' => 1]); + return new Estimate( + [Target::php('8.4'), new Target('acme/mail', '3.0')], + [ + $topic('php', 'deprecations', Phase::Jump), + $topic('acme/mail', 'deprecations', Phase::Jump), + $topic('upgrading.syntax.octalPrefix', 'modernizations', Phase::Jump), + $topic('php', 'deprecations', Phase::Bridge), + $topic('hygiene.uselessVersionCheck', 'cleanup', Phase::Jump), + ], + 1, + new BlindSpots, + $blockers ?? [new Blockers(new Target('acme/mail', '3.0'), [new Blocker('acme/shop', 'v1.0.0', '^2.0')])], + [], + [], + true, + false, + [], + library: $library, + ); +} + + +test('the steps go from what a human makes way for, through the bridge and the raise, to the jump and what is left', function () { + $project = new ProjectPackages(required: ['php' => '>=8.1', 'acme/mail' => '^2.4']); + $plan = new PlanBuilder($project)->build(createPlanEstimate(), 'vendor/bin/tester tests -o junit:{junit}'); + + Assert::equal([ + new ManualStep('make way for acme/mail 3.0: acme/shop', new Target('acme/mail', '3.0')), + new FixStep('works on the versions allowed today', ['php']), + // each constraint raised in the shape the project writes it + new ComposerStep('raise php to >=8.4, acme/mail to ^3.0', ['php' => '>=8.4', 'acme/mail' => '^3.0']), + new FixStep('needs the targets', ['php', 'acme/mail']), + new FixStep('code for the versions given up', ['hygiene.uselessVersionCheck']), + // the raise installs the lowest versions, which the application then updates, tested as every step + new UpdateStep('update to the newest versions'), + // what a newer PHP offers waits until the user takes it in + new FixStep('what the targets offer', ['upgrading.syntax.octalPrefix'], skip: true), + ], $plan->steps); + Assert::same( + [ + 1 => ManualStep::class, + 2 => FixStep::class, + 3 => ComposerStep::class, + 4 => FixStep::class, + 5 => FixStep::class, + 6 => UpdateStep::class, + ], + array_map(fn(Step $step) => $step::class, $plan->findSteps()), + ); + Assert::same('vendor/bin/tester tests -o junit:{junit}', $plan->testCommand); +}); + + +test('a tilde keeps its precision, and an upper bound of its own the target passes is said', function () { + $project = new ProjectPackages(required: ['php' => '~8.1.0', 'acme/mail' => '>=2.4 <3.0']); + $plan = new PlanBuilder($project)->build(createPlanEstimate()); + Assert::equal( + new ComposerStep('raise php to ~8.4.0, acme/mail to ^3.0 past its upper bound <3.0', ['php' => '~8.4.0', 'acme/mail' => '^3.0']), + $plan->steps[2], + ); +}); + + +test('an estimate is complete once it processed every file, had the upgrading data and Composer told what refuses', function () { + Assert::true(createPlanEstimate()->isComplete()); + Assert::false(createPlanEstimate(blockers: [new Blockers(new Target('acme/mail', '3.0'), unknown: 'Composer failed')])->isComplete()); +}); + + +test('a target Composer could not tell what refuses is a step for a human all the same', function () { + $project = new ProjectPackages(required: ['php' => '>=8.1', 'acme/mail' => '^2.4']); + $plan = new PlanBuilder($project)->build(createPlanEstimate( + blockers: [new Blockers(new Target('acme/mail', '3.0'), unknown: 'Composer failed')], + )); + Assert::equal( + new ManualStep('make way for acme/mail 3.0, which Composer could not tell', new Target('acme/mail', '3.0')), + $plan->steps[0], + ); +}); + + +test('a constraint is raised by its lower bound, its upper bound kept and a part the target is past dropped', function () { + foreach ([ + ['>=8.1', '>=8.4'], + ['^8.1', '^8.4'], + ['8.1', '^8.4'], + ['>=8.1 <8.5', '>=8.4 <8.5'], + ['>=8.1, <8.5', '>=8.4, <8.5'], + ['^8.1 <8.5', '^8.4 <8.5'], + ['^7.4 || ^8.0', '^8.4'], + ['^8.0 || ^9.0', '^8.4 || ^9.0'], + ['~8.1.0', '~8.4.0'], + ['>=8.1 <8.4', '^8.4'], + ['', '^8.4'], + // an operator apart from its version, and a range of a hyphen + ['>= 8.1 < 8.5', '>=8.4 <8.5'], + ['8.1 - 8.5', '8.4 - 8.5'], + ['^7.4 || 8.1 - 8.5', '8.4 - 8.5'], + ['8.1 - 8.3', '^8.4'], + ] as [$constraint, $raised]) { + Assert::same($raised, Constraints::raiseConstraint($constraint, '8.4'), $constraint); + Assert::true(DressCode\Config\Versions::isConstraint($raised), $raised); + } + + Assert::same('8.3', Constraints::findPassedUpperBound('8.1 - 8.3', '8.4')); +}); + + +test('a platform of PHP below the target is raised with the constraint, which the file of the plan keeps', function () { + $root = createTempDir('plan-platform'); + file_put_contents("$root/composer.json", '{"require": {"php": ">=8.1", "acme/mail": "^2.4"}, "config": {"platform": {"php": "8.1"}}}'); + $plan = new PlanBuilder(ProjectPackages::read($root))->build(createPlanEstimate()); + Assert::equal( + new ComposerStep( + 'raise php to >=8.4, acme/mail to ^3.0, config.platform.php to 8.4', + ['php' => '>=8.4', 'acme/mail' => '^3.0'], + platform: '8.4', + ), + $plan->steps[2], + ); + + PlanFile::write($plan, $root); + Assert::equal($plan->steps, PlanFile::read($root)->steps); + + // a platform below the target is raised where composer.json requires the target already, the step done once it is + file_put_contents("$root/composer.json", '{"require": {"php": "^8.4", "acme/mail": "^3.0"}, "config": {"platform": {"php": "8.0"}}}'); + $plan = new PlanBuilder(ProjectPackages::read($root))->build(createPlanEstimate()); + $step = new ComposerStep('raise config.platform.php to 8.4', [], platform: '8.4'); + Assert::equal($step, $plan->steps[2]); + Assert::same(['composer config platform.php 8.4', 'composer update --lock'], $step->formatCommands()); + Assert::false($step->isDoneIn(ProjectPackages::read($root))); + file_put_contents("$root/composer.json", '{"require": {"php": "^8.4", "acme/mail": "^3.0"}, "config": {"platform": {"php": "8.4"}}}'); + Assert::true($step->isDoneIn(ProjectPackages::read($root))); + + PlanFile::write($plan, $root); + Assert::equal($plan->steps, PlanFile::read($root)->steps); +}); + + +test('a target the lowest version allowed today already reaches raises nothing', function () { + $project = new ProjectPackages(required: ['php' => '^8.4', 'acme/mail' => '^3.1']); + $plan = new PlanBuilder($project)->build(createPlanEstimate()); + Assert::false(array_any($plan->steps, fn(Step $step) => $step instanceof ComposerStep)); +}); + + +test('the file of the plan gives back the plan it was written from', function () { + $root = createTempDir('plan-file'); + $plan = new PlanBuilder(new ProjectPackages(required: ['php' => '^8.1'])) + ->build(createPlanEstimate(), 'composer test -- {junit}', 'composer quick -- {junit}'); + PlanFile::write($plan, $root); + Assert::equal($plan, PlanFile::read($root)); + + // what the estimate could not see goes with the plan, so that a run of it says so + $plan = new Plan( + [Target::php('8.4')], + [new FixStep('fix', ['php'])], + '1.0', + unestimated: ['src/Broken.php'], + missingData: ['dresscode/upgrade-acme' => ['acme/mail']], + ); + PlanFile::write($plan, $root); + Assert::equal($plan, PlanFile::read($root)); +}); + + +test('the file of the plan is read as the team writes it, and one that is no plan is refused', function () { + $root = createTempDir('plan-edited'); + file_put_contents("$root/" . PlanFile::Name, <<<'XX' + version: 1 + targets: {php: '8.4', acme/mail: '3.0'} + tests: {command: 'vendor/bin/phpunit --log-junit {junit}'} + steps: + - {kind: manual, title: make way, target: acme/mail} + - {kind: fix, topics: [php]} + - {kind: composer, title: raise, require: {php: ^8.4}, skip: true} + XX); + Assert::equal( + new Plan( + [Target::php('8.4'), new Target('acme/mail', '3.0')], + [ + new ManualStep('make way', new Target('acme/mail', '3.0')), + new FixStep('fix', ['php']), + new ComposerStep('raise', ['php' => '^8.4'], skip: true), + ], + '', + testCommand: 'vendor/bin/phpunit --log-junit {junit}', + ), + PlanFile::read($root), + ); + + foreach ([ + "version: 1\nsteps: []\n" => '`targets` and `steps` are expected', + "targets: {php: '8.4'}\n" => '`version: 1` is expected, the plan of another version of DressCode is made anew', + "version: 1\ntarget: {php: '8.4'}\n" => '`target` is no key of a plan', + "version: 1\ntargets: {php: 8.4}\nsteps: []\n" => "`targets` must be a map of strings, a version quoted as `'8.4'`", + "version: 1\ntargets: {php: '8.4'}\nsteps:\n\t- {title: who knows}\n" => '`steps.1` must give its `kind`, `fix`, `composer`, `update` or `manual`', + "version: 1\ntargets: {php: '8.4'}\nsteps:\n\t- {kind: fix, topic: [php]}\n" => '`steps.1.topic` is no key of a step of the kind `fix`', + "version: 1\ntargets: {php: '8.4'}\nsteps:\n\t- {kind: fix, topics: [php], skip: falsee}\n" => '`steps.1.skip` must be `true` or `false`', + "version: 1\ntargets: {php: '8.4'}\nsteps:\n\t- {kind: fix}\n" => '`steps.1.topics` must name the topics the step fixes', + "version: 1\ntargets: {php: '8.4'}\nsteps:\n\t- {kind: composer}\n" => '`steps.1.require` must give the constraints the step writes, or the step its `platform`', + "version: 1\ntargets: {nonsense: '8.4'}\nsteps: []\n" => '`targets.nonsense` must be `php` or a package as `vendor/name`', + "version: 1\ntargets: {php: '8.4'}\nsteps:\n\t- {kind: composer, require: {garbage: ^1.0}}\n" + => '`steps.1.require.garbage` must be `php` or a package as `vendor/name`', + "version: 1\ntargets: {php: '8.4'}\nsteps:\n\t- {kind: composer, require: {php: no-constraint}}\n" + => '`steps.1.require.php` must be a constraint Composer reads, as `^8.4`', + "version: 1\ntargets: {php: '8.4'}\nsteps:\n\t- {kind: manual, target: acme/mail}\n" => '`steps.1.target` must name a package of `targets`', + "version: 1\ntargets: {php: '8.4'}\ntests: {quick: vendor/bin/phpunit}\nsteps:\n\t- {kind: fix, topics: [php]}\n" + => '`tests.quick` must be a command writing its JUnit report where `{junit}` stands', + "version: 1\ntargets: {php: '8.4'}\nestimate: {files: []}\nsteps:\n\t- {kind: fix, topics: [php]}\n" + => '`estimate.files` is no key of `estimate`, which takes `unprocessed` and `missingData`', + "version: 1\ntargets: {php: '8.4'}\nestimate: {missingData: {acme/x: acme/y}}\nsteps:\n\t- {kind: fix, topics: [php]}\n" + => '`estimate.missingData.acme/x` must be a list of strings', + "version: 1\ntargets: {php: '8.4'}\nestimate: {missingData: [acme/y]}\nsteps:\n\t- {kind: fix, topics: [php]}\n" + => '`estimate.missingData.0` must be a list of strings', + ] as $text => $message) { + file_put_contents("$root/" . PlanFile::Name, $text); + Assert::exception(fn() => PlanFile::read($root), DressCode\ConfigurationException::class, "The plan `%a%`: $message."); + } +}); + + +test('a library keeps the versions it allows: the bridge and the constraints widened to the targets', function () { + $project = new ProjectPackages(required: ['php' => '>=8.1', 'acme/mail' => '^2.4']); + $plan = new PlanBuilder($project)->build(createPlanEstimate(library: true)); + + Assert::equal([ + new ManualStep('make way for acme/mail 3.0: acme/shop', new Target('acme/mail', '3.0')), + new FixStep('works on the versions allowed today', ['php']), + new ComposerStep('widen acme/mail to ^2.4 || ^3.0', ['acme/mail' => '^2.4 || ^3.0'], update: false), + ], $plan->steps); +}); + + +test( + 'a library says so by its type, none being one as Composer reads it, and has no lock of its own, one with a lock maybe being one', + function () { + $root = createTempDir('plan-library'); + file_put_contents("$root/composer.json", '{"type": "library"}'); + Assert::same(Tristate::Yes, ProjectPackages::read($root)->isLibrary()); + file_put_contents("$root/composer.lock", '{}'); + Assert::same(Tristate::Maybe, ProjectPackages::read($root)->isLibrary()); + file_put_contents("$root/composer.json", '{}'); + Assert::same(Tristate::Maybe, ProjectPackages::read($root)->isLibrary()); + unlink("$root/composer.lock"); + $untyped = ProjectPackages::read($root); + Assert::same([Tristate::Yes, 'library'], [$untyped->isLibrary(), $untyped->findType()]); + file_put_contents("$root/composer.json", '{"type": "project"}'); + Assert::same(Tristate::No, ProjectPackages::read($root)->isLibrary()); + }, +); diff --git a/tests/DressCode/Upgrade/PlanRunner.phpt b/tests/DressCode/Upgrade/PlanRunner.phpt new file mode 100644 index 0000000000..4de40df29e --- /dev/null +++ b/tests/DressCode/Upgrade/PlanRunner.phpt @@ -0,0 +1,970 @@ +' : '>') . ''); + XX); + return $root; +} + + +function createSteppedRunProject(string $root): Project +{ + return new Project(Config\Loader::loadFile("$root/dresscode.neon"), $root, "$root/dresscode.neon"); +} + + +/** Composer as a test scripts it: a raise writes its constraints into `composer.json`, an update changes what it is given until it ran. */ +final class PlanRunnerComposer implements PackageManager +{ + public int $updates = 0; + + + public function __construct( + private string $root, + /** @var ?Closure(array): void what a raise does instead */ + private ?Closure $onRequire = null, + /** @var list what an update changes, until it ran */ + private array $updateChanges = [], + /** @var ?Closure(): void what an install does */ + private ?Closure $onInstall = null, + ) { + } + + + public function findBlockers(Target $target): Blockers + { + return new Blockers($target, unknown: 'not asked'); + } + + + public function previewRequire(array $constraints, bool $update = true, ?string $platform = null): array + { + return []; + } + + + public function require(array $constraints, bool $update = true, ?string $platform = null): void + { + $this->onRequire === null + ? file_put_contents("$this->root/composer.json", json_encode(['require' => $constraints], JSON_THROW_ON_ERROR)) + : ($this->onRequire)($constraints); + } + + + public function previewUpdate(): array + { + return $this->updates === 0 ? $this->updateChanges : []; + } + + + public function update(): void + { + $this->updates++; + } + + + public function install(): void + { + $this->onInstall?->__invoke(); + } +} + + +function createSteppedPlan(?string $testCommand = null): Plan +{ + return new Plan( + [Target::php('8.4')], + [ + new FixStep('works on the versions allowed today', ['upgrading.php.implicitNullable']), + new ComposerStep('raise php to ^8.4', ['php' => '^8.4']), + ], + '1.0', + testCommand: $testCommand, + ); +} + + +test('the results of the tests are read from JUnit as PHPUnit and Nette Tester write it, a test by its class and name', function () { + $results = TestResults::fromJunit(<<<'XX' + + + + broken + + + + XX, 'W:/acme') ?? throw new RuntimeException; + + Assert::same([ + 'App.Tests.MailTest::testSend with data set #0' => TestStatus::Passed, + 'App/Tests/MailTest::testQueue' => TestStatus::Failed, + 'tests/Mail.phpt::W:/acme/tests/Mail.phpt' => TestStatus::Error, + ], $results->tests); + // what failed before, or passes now, is no new failure + Assert::same( + ['tests/Mail.phpt::W:/acme/tests/Mail.phpt (error)'], + $results->findNewFailures(new TestResults(['App/Tests/MailTest::testQueue' => TestStatus::Failed])), + ); + + // the results a run keeps are read back as they were written, those of another shape not at all + $file = createTempDir('test-results') . '/results.json'; + $results->save($file); + Assert::same($results->tests, TestResults::fromFile($file)?->tests); + file_put_contents($file, '{"A::one": true}'); + Assert::null(TestResults::fromFile($file)); + Assert::null(TestResults::fromJunit('no xml', 'W:/acme')); + Assert::null(TestResults::fromJunit('', 'W:/acme')); +}); + + +test('a test gone, skipped now or failing under one of its repeated identities is a new failure', function () { + $baseline = new TestResults(['A::one' => TestStatus::Passed, 'A::two' => TestStatus::Passed, 'A::three' => TestStatus::Failed]); + Assert::same(['A::one (gone)', 'A::two (gone)'], TestResults::fromJunit('', '')?->findNewFailures($baseline)); + + $results = TestResults::fromJunit(<<<'XX' + + + + + + XX, ''); + Assert::same(['A::one (skipped)', 'A::two'], $results?->findNewFailures($baseline)); +}); + + +test('a run says by its exit code whether it is done, leaves work for a human, is incomplete or waits outside DressCode', function () { + $codes = []; + foreach (RunStop::cases() as $stop) { + $codes[$stop->name] = new RunOutcome($stop)->getExitCode(); + } + + Assert::same([ + 'Done' => 0, + 'Composer' => 4, + 'Raised' => 4, + 'Manual' => 4, + 'Tasks' => 1, + 'Tests' => 1, + 'Unprocessed' => 2, + 'Remaining' => 1, + 'Interrupted' => 1, + 'Commit' => 2, + ], $codes); + // tests that do not run before the first step leave the run incomplete, a crash after a step goes back as a failure + Assert::same(2, new RunOutcome(RunStop::Tests, crash: 'it wrote no results')->getExitCode()); + Assert::same(1, new RunOutcome(RunStop::Tests, 1, crash: 'it wrote no results')->getExitCode()); +}); + + +test('a fix step runs over the project as it is and stops before the raise of Composer, which it waits for', function () { + $root = createSteppedProject('steps-blocked'); + $generated = " ['upgrading.php.implicitNullable' => ['src/Code.php']]]), + $runner->run(createSteppedPlan()), + ); + Assert::same(" ['upgrading.php.implicitNullable' => []]]), $runner->run(createSteppedPlan())); +}); + + +test('a step whose tests newly fail goes back byte for byte, and a command writing no results is refused', function () { + $root = createSteppedProject('steps-failed'); + $code = file_get_contents("$root/src/Code.php"); + $runner = new PlanRunner(createSteppedRunProject($root)); + $php = escapeshellarg(PHP_BINARY); + + $outcome = $runner->run(createSteppedPlan("$php tests.php {junit}")); + // a test of Nette Tester is named by its file within the root + Assert::same([RunStop::Tests, 1, ['tests/Code.phpt::run']], [$outcome->stop, $outcome->step, $outcome->newFailures]); + Assert::same($code, file_get_contents("$root/src/Code.php")); + // what the tests were compared with is said, a test failing before the first step being no regression + Assert::same([2, 0], [count($outcome->baseline->tests ?? []), $outcome->baseline?->countNotPassed()]); + // and a step sent back applied nothing + Assert::same([], $outcome->applied); + Assert::match( + "Tests compared with their run before the first step, %d%-%d%-%d% %d%:%d%, where 0 of 2 did not pass\n%A%", + new DressCode\Console\RunOutcomePrinter(createSteppedPlan(), $outcome)->print(new Nette\CommandLine\Console), + ); + + $outcome = new PlanRunner(new Project(new Config(paths: ['src'], cacheDir: 'other'), $root, "$root/dresscode.neon"))->run( + createSteppedPlan("$php -r 1;"), + ); + Assert::same([RunStop::Tests, null, []], [$outcome->stop, $outcome->step, $outcome->applied]); + Assert::match( + 'Failed the tests of `%a%` do not run before the first step, as the command of the tests wrote no JUnit report where `{junit}` stands, so nothing was changed', + new DressCode\Console\RunOutcomePrinter(createSteppedPlan(), $outcome)->print(new Nette\CommandLine\Console), + ); + + // a report of no test is no result, nor is an exit code no runner ends with for its failing tests + file_put_contents("$root/report.php", <<<'XX' + '; + file_put_contents($argv[1], "$case"); + exit((int) $argv[3]); + XX); + $report = fn(string $cases, int $exit) => new DressCode\Upgrade\ProcessTestRunner()->run( + escapeshellarg(PHP_BINARY) . " report.php {junit} $cases $exit", + $root, + )->crash; + Assert::same('the report of the tests holds no test', $report('none', 0)); + Assert::same('the command of the tests ended with exit code 255', $report('failing', 255)); + Assert::null($report('failing', 1)); +}); + + +test('a raise no fix follows is tested after it all the same, its dependencies staying raised where the tests fail', function () { + $root = createSteppedProject('steps-raise-tested'); + file_put_contents("$root/tests.php", <<<'XX' + ' : '>') . ''); + XX); + $plan = new Plan( + [Target::php('8.4')], + [new ComposerStep('raise php to ^8.4', ['php' => '^8.4'])], + '1.0', + testCommand: escapeshellarg(PHP_BINARY) . ' tests.php {junit}', + ); + // the run goes on after the raise in a process that loads what it installed, the tests and the check at the end too + $runner = new PlanRunner(createSteppedRunProject($root), composer: new PlanRunnerComposer($root)); + $raised = $runner->run($plan, approve: fn() => true); + Assert::same([RunStop::Raised, 1], [$raised->stop, $raised->step]); + $outcome = $runner->run($plan, resumed: $raised); + Assert::same([RunStop::Tests, 1, ['Raise::raise']], [$outcome->stop, $outcome->step, $outcome->newFailures]); + Assert::same('xx', file_get_contents("$root/runs.txt")); // before the first step and after the raise + // a run started anew, as one that could not go on in a process of its own is, finds the raise untested in the journal + $again = $runner->run($plan); + Assert::same([RunStop::Tests, 1, ['Raise::raise']], [$again->stop, $again->step, $again->newFailures]); + Assert::same('xxx', file_get_contents("$root/runs.txt")); + Assert::same(['composer.json'], $outcome->raisedFiles); + Assert::match( + "%A%Raised composer.json changed, which `dresscode upgrade abort` puts back as they were before the raise and installs\n" + . 'Failed step 1 raised the dependencies, which stay raised, and these tests now fail: Raise::raise%A%', + new DressCode\Console\RunOutcomePrinter($plan, $outcome)->print(new Nette\CommandLine\Console), + ); + + // the journal keeps the files until a step after the raise is done, or the user puts them back + $state = new RunState(createSteppedRunProject($root)); + Assert::same(1, $state->journal->findStep()); + $restoration = $state->restore(createSteppedRunProject($root), new PlanRunnerComposer($root)); + Assert::same([['composer.json'], [], false], [$restoration->restored, $restoration->left, $restoration->installs]); + Assert::same('{"require": {"php": "^8.0"}}', file_get_contents("$root/composer.json")); + Assert::null($state->journal->findStep()); +}); + + +test('an install of what abort put back that fails is tried again by the next abort, which forgets the journal only then', function () { + $root = createSteppedProject('steps-abort-install'); + file_put_contents("$root/composer.lock", '{}'); + $project = createSteppedRunProject($root); + $state = new RunState($project); + $state->journal->recordBefore(2, $root); + file_put_contents("$root/composer.json", '{"require": {"php": "^8.4"}}'); + $state->journal->recordAfter($root); + $failures = 1; + $composer = new PlanRunnerComposer($root, onInstall: function () use (&$failures): void { + if ($failures-- > 0) { + throw new ComposerException('Composer failed: the network is down'); + } + }); + + $first = $state->restore($project, $composer); + Assert::same( + [['composer.json'], true, 'Composer failed: the network is down'], + [$first->restored, $first->installs, $first->installFailure], + ); + Assert::same([2, true], [$state->journal->findStep(), $state->journal->isAwaitingInstall()]); + + $dryRun = $state->restore($project, $composer, dryRun: true); + Assert::same([[], true], [$dryRun->restored, $dryRun->installs]); + + $second = $state->restore($project, $composer); + Assert::same([[], true, null], [$second->restored, $second->installs, $second->installFailure]); + Assert::null($state->journal->findStep()); +}); + + +test('the journal of Composer puts back what the raise changed, a file it created removed, a file changed since left', function () { + $root = createTempDir('composer-journal'); + $journal = new DressCode\Upgrade\ComposerJournal("$root/state"); + file_put_contents("$root/composer.json", 'before'); + $journal->recordBefore(2, $root); + file_put_contents("$root/composer.json", 'raised'); + file_put_contents("$root/composer.lock", 'lock'); + $journal->recordAfter($root); + $journal->recordBefore(3, $root); // a later raise keeps what was before the first one + Assert::same([2, ['composer.json', 'composer.lock']], [$journal->findStep(), $journal->findChanged()]); + + file_put_contents("$root/composer.lock", 'edited'); + Assert::same( + [['composer.json'], ['composer.lock' => 'The file changed after the raise of step 2, so it is not written back.']], + $journal->restore($root), + ); + Assert::same('before', file_get_contents("$root/composer.json")); + Assert::same('edited', file_get_contents("$root/composer.lock")); + // a file left stays kept, to go back once the change of it is undone, and one written back is no change any more + Assert::same([2, ['composer.lock'], true], [$journal->findStep(), $journal->findChanged(), $journal->isAwaitingInstall()]); + file_put_contents("$root/composer.lock", 'lock'); + Assert::same([['composer.lock'], []], $journal->restore($root)); + Assert::same([[], false], [$journal->findChanged(), $journal->isAwaitingInstall()]); + $journal->forget(); + + file_put_contents("$root/composer.lock", 'edited'); + $journal->recordBefore(2, $root); + file_put_contents("$root/composer.lock", 'new'); + $journal->recordAfter($root); + Assert::same([['composer.lock'], []], $journal->restore($root)); + Assert::same('edited', file_get_contents("$root/composer.lock")); + $journal->forget(); + + unlink("$root/composer.lock"); + $journal->recordBefore(2, $root); + file_put_contents("$root/composer.lock", 'created'); + $journal->recordAfter($root); + Assert::same([['composer.lock'], []], $journal->restore($root)); + Assert::false(is_file("$root/composer.lock")); +}); + + +test('a command of the tests that crashes after writing a report of passing tests fails the step, and the baseline too', function () { + $root = createSteppedProject('steps-crashed'); + $code = file_get_contents("$root/src/Code.php"); + file_put_contents("$root/crash.php", <<<'XX' + '); + if (str_contains(file_get_contents(__DIR__ . '/src/Code.php'), '?int') || isset($argv[2])) { + echo "Fatal error: shutdown\n"; + exit(7); + } + XX); + $php = escapeshellarg(PHP_BINARY); + + $outcome = new PlanRunner(createSteppedRunProject($root))->run(createSteppedPlan("$php crash.php {junit}")); + Assert::same([RunStop::Tests, 1, []], [$outcome->stop, $outcome->step, $outcome->newFailures]); + Assert::same('the command of the tests ended with exit code 7: Fatal error: shutdown', $outcome->crash); + Assert::same($code, file_get_contents("$root/src/Code.php")); + + $outcome = new PlanRunner(new Project(new Config(paths: ['src'], cacheDir: 'other'), $root, "$root/dresscode.neon"))->run( + createSteppedPlan("$php crash.php {junit} always"), + ); + Assert::match( + 'the tests of `%a%` do not run before the first step, as the command of the tests ended with exit code 7: Fatal error: shutdown', + (string) $outcome->crash, + ); +}); + + +test('a run interrupted in a step stops the next one before it runs anything, unless it is told to discard what was kept', function () { + $root = createSteppedProject('steps-interrupted'); + $project = createSteppedRunProject($root); + new RunState($project)->snapshots->record(1, 'src/Code.php', 'before', 'after'); + $runner = new PlanRunner($project); + + Assert::equal(new RunOutcome(RunStop::Interrupted, 1, interrupted: ['src/Code.php']), $runner->run(createSteppedPlan())); + Assert::same([1 => ['src/Code.php']], new RunState($project)->snapshots->findInterrupted()); + Assert::same(RunStop::Composer, $runner->run(createSteppedPlan(), discard: true)->stop); + Assert::same(RunStop::Composer, $runner->run(createSteppedPlan())->stop); + + // snapshots that cannot be read stop the run all the same, with the files unknown + FileSystem::write(new RunState($project)->directory . '/snapshots/2/index.json', '{broken'); + Assert::equal(new RunOutcome(RunStop::Interrupted, 2), $runner->run(createSteppedPlan())); +}); + + +test('a run is refused while another one works in the project, and going back forgets a file its step did not write yet', function () { + $root = createSteppedProject('steps-locked'); + $project = createSteppedRunProject($root); + $state = new RunState($project); + Assert::exception( + fn() => $state->runExclusively(fn() => new PlanRunner($project)->run(createSteppedPlan())), + DressCode\ConfigurationException::class, + 'Another run of the upgrade works in the project right now; wait until it ends.', + ); + + $code = (string) file_get_contents("$root/src/Code.php"); + $state->snapshots->record(1, 'src/Code.php', $code, 'after'); + Assert::same([], $state->snapshots->restore(1, $root)); + Assert::same($code, file_get_contents("$root/src/Code.php")); + Assert::same([], $state->snapshots->findInterrupted()); +}); + + +test('a file the fix cannot process stops the run after its step, and going back leaves a file changed since as it is', function () { + $root = createSteppedProject('steps-errors'); + file_put_contents("$root/src/Broken.php", "run(createSteppedPlan()); + Assert::same([RunStop::Unprocessed, 1], [$outcome->stop, $outcome->step]); + Assert::match('Syntax error: %a%', $outcome->unprocessed['src/Broken.php']); + Assert::contains('?int', (string) file_get_contents("$root/src/Code.php")); + // the tasks of the plan say so too, as nothing of the file is checked + Assert::match( + 'Syntax error: %a%', + new PlanRunner(createSteppedRunProject($root))->findTasks(createSteppedPlan())[1]['src/Broken.php'] ?? '', + ); + + // the tests change the file the step wrote, as a user editing it while they run would + $root = createSteppedProject('steps-conflict'); + file_put_contents("$root/edit.php", <<<'XX' + run(createSteppedPlan(escapeshellarg(PHP_BINARY) . ' edit.php {junit}')); + Assert::same(RunStop::Tests, $outcome->stop); + Assert::match( + 'The file changed after step 1 wrote it, so it is not written back; its text before the step is kept in `%a%`.', + $outcome->unprocessed['src/Code.php'], + ); + Assert::contains("// edited\n", (string) file_get_contents("$root/src/Code.php")); + // the text before the step stays until the user decides, as for a step that did not finish + $runner = new PlanRunner(createSteppedRunProject($root)); + Assert::equal(new RunOutcome(RunStop::Interrupted, 1, interrupted: ['src/Code.php']), $runner->run(createSteppedPlan())); +}); + + +test('with commit, each topic is committed with the files it changed once the tests of its step pass', function () { + $root = createSteppedProject('steps-commit'); + foreach (['GIT_AUTHOR_NAME', 'GIT_COMMITTER_NAME'] as $name) { + putenv("$name=Test"); + } + + foreach (['GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_EMAIL'] as $name) { + putenv("$name=test@example.com"); + } + + $git = fn(string ...$args) => ProcessRunner::run(['git', ...array_values($args)], $root, 60); + $git('init', '--quiet'); + $git('commit', '--quiet', '--allow-empty', '-m', 'start'); + + $runner = new PlanRunner(createSteppedRunProject($root), commit: true); + + // a change of the working tree would go into the commit of a topic changing that file + $git('add', 'src/Code.php'); + $git('commit', '--quiet', '-m', 'code'); + file_put_contents("$root/src/Code.php", "\n", FILE_APPEND); + Assert::exception( + fn() => $runner->run(createSteppedPlan()), + DressCode\ConfigurationException::class, + 'A commit of a topic takes the files it changed whole, and the working tree has changes of its own; %a%', + ); + $git('checkout', '--', 'src/Code.php'); + // the plan the run saves is no change of the code + file_put_contents("$root/dresscode-upgrade.neon", "# plan\n"); + $git('add', 'dresscode-upgrade.neon'); + $git('commit', '--quiet', '-m', 'plan'); + file_put_contents("$root/dresscode-upgrade.neon", "# plan saved anew\n"); + + $runner->run(createSteppedPlan()); + [, $log] = $git('log', '-1', '--name-only'); + Assert::match( + "%A% upgrade: upgrading.php.implicitNullable\n\n DressCode-Upgrade-Step: 1\n\nsrc/Code.php\n", + str_replace("\r", '', $log), + ); + + // a raise the run makes is committed too, so that the next run finds no changes of its own + $runner = new PlanRunner(createSteppedRunProject($root), commit: true, composer: new PlanRunnerComposer($root)); + $raised = $runner->run(createSteppedPlan(), approve: fn() => true); + Assert::same(RunStop::Done, $runner->run(createSteppedPlan(), resumed: $raised)->stop); + Assert::contains('upgrade: raise php to ^8.4', $git('log', '-1')[1]); + Assert::false($runner->hasOwnChanges()); + file_put_contents("$root/composer.json", '{"require": {"php": "^8.0"}}'); + $git('commit', '--quiet', '-am', 'php ^8.0'); + + // a commit git refuses ends the run, what the step changed left in the working tree + file_put_contents("$root/src/Code.php", "run(createSteppedPlan()); + Assert::same( + [RunStop::Commit, 1, '`git commit` failed: refused by the hook; what step 1 changed is left uncommitted in the working tree.'], + [$outcome->stop, $outcome->step, $outcome->commitFailure], + ); + Assert::contains('?int', (string) file_get_contents("$root/src/Code.php")); +}); + + +test('with commit, a project git finds no repository of is refused before anything changes', function () { + $root = createSteppedProject('steps-commit-no-git'); + $code = file_get_contents("$root/src/Code.php"); + Assert::exception( + fn() => new PlanRunner(createSteppedRunProject($root), commit: true)->run(createSteppedPlan()), + DressCode\ConfigurationException::class, + '`--commit` commits into the git repository of the project, and git finds none there that tracks it, or refuses to tell its status.', + ); + Assert::same($code, file_get_contents("$root/src/Code.php")); +}); + + +test('with commit, a file the tests change after its step wrote it is kept as it is, and the step stays uncommitted', function () { + $root = createSteppedProject('steps-commit-edited'); + file_put_contents("$root/edit.php", <<<'XX' + '); + XX); + $git = fn(string ...$args) => ProcessRunner::run(['git', ...array_values($args)], $root, 60); + $git('init', '--quiet'); + $git('add', '--all'); + $git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'start'); + + $outcome = new PlanRunner(createSteppedRunProject($root), commit: true)->run( + createSteppedPlan(escapeshellarg(PHP_BINARY) . ' edit.php {junit}'), + ); + Assert::same( + [ + RunStop::Commit, + '`src/Code.php` changed after step 1 wrote it, so the step is left uncommitted in the working tree; commit it yourself.', + ], + [$outcome->stop, $outcome->commitFailure], + ); + $code = (string) file_get_contents("$root/src/Code.php"); + Assert::contains('?int', $code); + Assert::contains("// edited\n", $code); + Assert::match("%A% start\n", str_replace("\r", '', $git('log', '-1')[1])); +}); + + +test('with commit, a step leaving a file it cannot process is committed whole once a later run processes it', function () { + $root = createSteppedProject('steps-commit-errors'); + file_put_contents("$root/src/Broken.php", " ProcessRunner::run(['git', ...array_values($args)], $root, 60); + $git('init', '--quiet'); + $git('add', '--all'); + $git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '--quiet', '-m', 'start'); + $runner = new PlanRunner(createSteppedRunProject($root), commit: true); + + Assert::same(RunStop::Unprocessed, $runner->run(createSteppedPlan())->stop); + Assert::match("%A% start\n", str_replace("\r", '', $git('log', '-1')[1])); + Assert::contains('?int', (string) file_get_contents("$root/src/Code.php")); + + // what the run keeps lies in the repository, and a plan whose step 1 is another one now refuses to commit it + Assert::true(is_dir("$root/.git/dresscode-upgrade")); + $edited = new Plan([Target::php('8.4')], [new FixStep('edited', ['upgrading.php.deprecatedApi'])], '1.0'); + Assert::exception( + fn() => $runner->run($edited), + DressCode\ConfigurationException::class, + 'The plan changed since a run left step 1 uncommitted, waiting for files it could not process; %a%', + ); + + // a file the step wrote and someone changed since is a change of their own, which the commit would take + $written = (string) file_get_contents("$root/src/Code.php"); + file_put_contents("$root/src/Code.php", "$written// mine\n"); + Assert::exception( + fn() => $runner->run(createSteppedPlan()), + DressCode\ConfigurationException::class, + 'A commit of a topic takes the files it changed whole, and the working tree has changes of its own; %a%', + ); + file_put_contents("$root/src/Code.php", $written); + + // the user makes the file parse, which is no change of their own for the run, and the step goes into one commit + file_put_contents("$root/src/Broken.php", "run(createSteppedPlan())->stop); + Assert::match( + "%A% upgrade: upgrading.php.implicitNullable\n%A%\nsrc/Broken.php\nsrc/Code.php\n", + str_replace("\r", '', $git('log', '-1', '--name-only')[1]), + ); +}); + + +test('a fix resolves the names with what the namespaces declare, as the estimate does', function () { + $root = createSteppedProject('steps-names'); + file_put_contents("$root/src/Close.php", "run($plan)->applied[1]['php']); + Assert::notContains('curl_close', (string) file_get_contents("$root/src/Close.php")); +}); + + +test('a plan leaving a task is not done, and a step it does not run is refused', function () { + $root = createSteppedProject('steps-left'); + file_put_contents("$root/src/Date.php", "run($plan); + Assert::same([RunStop::Tasks, 1], [$outcome->stop, $outcome->step]); + Assert::same(['src/Date.php'], array_map(fn(Task $task) => $task->path, $outcome->tasks['php'])); + + Assert::exception( + fn() => $runner->run($plan, singleStep: 3), + DressCode\ConfigurationException::class, + 'The plan has no step 3; it has 2.', + ); + Assert::exception( + fn() => $runner->run($plan, singleStep: 2), + DressCode\ConfigurationException::class, + 'Step 2 of the plan is left out with `skip: true`, so it is not run.', + ); +}); + + +test('a step of Composer that updates is done once the version installed is one it allows, not the constraint alone', function () { + $root = createSteppedProject('steps-installed'); + file_put_contents("$root/composer.json", '{"require": {"php": "^8.0", "acme/mail": "^4.0"}}'); + $installed = fn(string $version) => FileSystem::write( + "$root/vendor/composer/installed.json", + json_encode(['packages' => [['name' => 'acme/mail', 'version_normalized' => $version]]], JSON_THROW_ON_ERROR), + ); + $plan = new Plan( + [new Target('acme/mail', '4.0')], + [new ComposerStep('raise acme/mail to ^4.0', ['acme/mail' => '^4.0'])], + '1.0', + ); + $runner = new PlanRunner(createSteppedRunProject($root)); + + $installed('3.2.0.0'); + Assert::same(RunStop::Composer, $runner->run($plan)->stop); + $installed('4.0.0.0'); + // the plan is done once the targets find nothing in the whole scope, which no step of this one fixes + Assert::equal( + new RunOutcome(RunStop::Remaining, remaining: ['src/Code.php']), + $runner->run($plan), + ); + file_put_contents("$root/src/Code.php", "run($plan)->stop); + + // the lock holds what an install gives, so it must allow the version too + $lock = fn(string $version) => file_put_contents( + "$root/composer.lock", + json_encode(['packages' => [['name' => 'acme/mail', 'version' => $version]], 'packages-dev' => []], JSON_THROW_ON_ERROR), + ); + $lock('v3.2.0'); + Assert::same(RunStop::Composer, $runner->run($plan)->stop); + $lock('v4.0.0'); + Assert::same(RunStop::Done, $runner->run($plan)->stop); +}); + + +test('a step raising PHP is done once config.platform.php allows the version too, which Composer resolves for', function () { + $root = createSteppedProject('steps-platform'); + $composer = fn(string $platform) => file_put_contents( + "$root/composer.json", + json_encode(['require' => ['php' => '^8.4'], 'config' => ['platform' => ['php' => $platform]]], JSON_THROW_ON_ERROR), + ); + $plan = new Plan([Target::php('8.4')], [new ComposerStep('raise php to ^8.4', ['php' => '^8.4'], platform: '8.4')], '1.0'); + $runner = new PlanRunner(createSteppedRunProject($root)); + file_put_contents("$root/src/Code.php", "run($plan)->stop); + $composer('8.4'); + Assert::same(RunStop::Done, $runner->run($plan)->stop); +}); + + +test('a manual step passes once Composer says nothing of the lock refuses its target', function () { + $root = createSteppedProject('steps-manual'); + file_put_contents("$root/src/Code.php", " file_put_contents("$root/composer.lock", json_encode([ + 'packages' => [ + ['name' => 'acme/shop', 'version' => '1.2.0', 'require' => ['acme/mail' => $constraint]], + ['name' => 'acme/mail', 'version' => '2.1.0'], + ], + 'packages-dev' => [], + ], JSON_THROW_ON_ERROR)); + $target = new Target('acme/mail', '3.0'); + $plan = new Plan([$target], [new ManualStep('make way for acme/mail 3.0: acme/shop', $target)], '1.0'); + $runner = new PlanRunner(createSteppedRunProject($root)); + + $lock('^2.0'); + $outcome = $runner->run($plan); + Assert::same([RunStop::Manual, 1], [$outcome->stop, $outcome->step]); + Assert::same(['acme/shop'], array_map(fn(Blocker $blocker) => $blocker->package, $outcome->blockers->packages ?? [])); + + $lock('^2.0 || ^3.0'); // the project itself refusing it is left out + Assert::same(RunStop::Done, $runner->run($plan)->stop); + + // without a lock nothing tells, so the step waits as for an answer Composer did not give + unlink("$root/composer.lock"); + $outcome = $runner->run($plan); + Assert::same([RunStop::Manual, 'the project has no `composer.lock` to ask'], [$outcome->stop, $outcome->blockers?->unknown]); +}); + + +test( + 'a raise approved lets the run go on once Composer made it, one it did not make or that is not approved leaves the run waiting', + function () { + $root = createSteppedProject('steps-raised'); + $project = createSteppedRunProject($root); + $asked = []; + $approve = function (DependencyStep $step, array $changes) use (&$asked): bool { + $asked[] = $step->title; + return true; + }; + $runner = new PlanRunner($project, composer: new PlanRunnerComposer($root, onRequire: fn() => null)); + Assert::same(2, $runner->run(createSteppedPlan(), approve: $approve)->step); + Assert::same(['raise php to ^8.4'], $asked); + Assert::same( + [RunStop::Composer, 2], + [($outcome = $runner->run(createSteppedPlan(), approve: fn() => false))->stop, $outcome->step], + ); + + // where Composer fails, the outcome says why, beside what the run tells + $fail = fn() => throw new DressCode\Upgrade\ComposerException('Composer failed: Your requirements could not be resolved'); + $runner = new PlanRunner($project, composer: new PlanRunnerComposer($root, onRequire: $fail)); + $outcome = $runner->run(createSteppedPlan(), approve: fn() => true)->withWarnings(['The types are not there.']); + Assert::same( + [RunStop::Composer, 'Composer failed: Your requirements could not be resolved', ['The types are not there.']], + [$outcome->stop, $outcome->composerFailure, $outcome->warnings], + ); + Assert::match( + "%A%Warning The types are not there.\nFailed step 2, Composer failed: Your requirements could not be resolved; raise php to ^8.4: run %A%", + new DressCode\Console\RunOutcomePrinter(createSteppedPlan(), $outcome)->print(new Nette\CommandLine\Console), + ); + + $runner = new PlanRunner($project, composer: new PlanRunnerComposer($root)); + $raised = $runner->run(createSteppedPlan(), approve: fn() => true); + Assert::same([RunStop::Raised, 2], [$raised->stop, $raised->step]); + Assert::equal( + new RunOutcome(RunStop::Done, null, [1 => ['upgrading.php.implicitNullable' => []]]), + $runner->run(createSteppedPlan(), resumed: $raised), + ); + }, +); + + +test('the files of Composer a raise changes are kept to go back where they lie above the root of the configuration', function () { + $above = createTempDir('steps-composer-above'); + mkdir("$above/app"); + $root = "$above/app"; + foreach (['src', 'dresscode.neon', 'tests.php'] as $entry) { + rename(createSteppedProject('steps-composer-below') . "/$entry", "$root/$entry"); + } + + file_put_contents("$above/composer.json", '{"require": {"php": "^8.0"}}'); + $runner = new PlanRunner(createSteppedRunProject($root), composer: new PlanRunnerComposer($above)); + $plan = new Plan([Target::php('8.4')], [new ComposerStep('raise php to ^8.4', ['php' => '^8.4'])], '1.0'); + $raised = $runner->run($plan, approve: fn() => true); + Assert::same(RunStop::Remaining, $runner->run($plan, resumed: $raised)->stop); // the bridge is not in the plan + Assert::same(['composer.json'], $runner->state->journal->findChanged()); + Assert::same([['composer.json'], []], $runner->state->journal->restore(createSteppedRunProject($root)->findComposerRoot())); + Assert::same('{"require": {"php": "^8.0"}}', file_get_contents("$above/composer.json")); +}); + + +test('the update to the newest versions is done once Composer says it would change nothing, and asked for otherwise', function () { + $root = createSteppedProject('steps-newest'); + file_put_contents("$root/src/Code.php", " v3.2.1)']); + $runner = new PlanRunner(createSteppedRunProject($root), composer: $composer); + $asked = []; + $approve = function (DependencyStep $step, array $changes) use (&$asked): bool { + $asked[] = $changes; + return true; + }; + $raised = $runner->run($plan, approve: $approve); + Assert::same([RunStop::Raised, RunStop::Done], [$raised->stop, $runner->run($plan, resumed: $raised)->stop]); + Assert::same(RunStop::Done, $runner->run($plan, approve: $approve)->stop); + Assert::same([['- Upgrading acme/mail (v3.0.0 => v3.2.1)']], $asked); // done, so not asked again + Assert::same(1, $composer->updates); + Assert::same(['composer update'], $step->formatCommands()); +}); + + +test('Composer and the tests are boundaries a test replaces, to fail exactly where it wants', function () { + $root = createSteppedProject('steps-boundaries'); + $composer = new class implements PackageManager { + public function findBlockers(Target $target): Blockers + { + return new Blockers($target, unknown: 'Composer did not answer within 60 s'); + } + + + public function previewRequire(array $constraints, bool $update = true, ?string $platform = null): array + { + return []; + } + + + public function require(array $constraints, bool $update = true, ?string $platform = null): void + { + } + + + public function previewUpdate(): array + { + throw new ComposerException('Composer failed: the network is down'); + } + + + public function update(): void + { + } + + + public function install(): void + { + } + }; + $tests = new class implements TestRunner { + public function run(string $command, string $root): TestResults + { + return new TestResults(['A::a' => TestStatus::Passed], crash: 'the command of the tests ended with exit code 255'); + } + }; + $project = createSteppedRunProject($root); + $target = new Target('acme/mail', '3.0'); + + $runner = new PlanRunner($project, composer: $composer); + $outcome = $runner->run(new Plan([$target], [new ManualStep('make way for acme/mail 3.0', $target)], '1.0')); + Assert::same([RunStop::Manual, 'Composer did not answer within 60 s'], [$outcome->stop, $outcome->blockers?->unknown]); + $outcome = $runner->run(new Plan([$target], [new UpdateStep('update to the newest versions')], '1.0')); + Assert::same([RunStop::Composer, 'Composer failed: the network is down'], [$outcome->stop, $outcome->composerFailure]); + + Assert::same( + 'the tests of `tests {junit}` do not run before the first step, as the command of the tests ended with exit code 255', + new PlanRunner($project, tests: $tests)->run(createSteppedPlan('tests {junit}'))->crash, + ); +}); + + +test('a raise a fix follows stops the run, which goes on after it from the outcome handed over as data', function () { + $root = createSteppedProject('steps-resumed'); + $plan = new Plan([Target::php('8.4')], [ + new FixStep('works on the versions allowed today', ['upgrading.php.implicitNullable']), + new ComposerStep('raise php to ^8.4', ['php' => '^8.4']), + new FixStep('needs the targets', ['upgrading.php.implicitNullable']), + ], '1.0'); + $runner = new PlanRunner(createSteppedRunProject($root), composer: new PlanRunnerComposer($root)); + $outcome = $runner->run($plan, approve: fn() => true); + Assert::equal(new RunOutcome(RunStop::Raised, 2, [1 => ['upgrading.php.implicitNullable' => ['src/Code.php']]]), $outcome); + + $handed = RunOutcome::fromArray(json_decode(json_encode($outcome->toArray(), JSON_THROW_ON_ERROR), associative: true)); + Assert::equal($outcome, $handed); + Assert::equal( + new RunOutcome(RunStop::Done, null, [ + 1 => ['upgrading.php.implicitNullable' => ['src/Code.php']], + 3 => ['upgrading.php.implicitNullable' => []], + ]), + $runner->run($plan, resumed: $handed), + ); + + // what a fix left goes over as data too + $violation = new DressCode\Violation( + 'upgrading.php.deprecatedApi', + 'Deprecated.', + 3, + 5, + DressCode\Severity::Error, + 'abc', + fixable: false, + ); + $outcome = new RunOutcome(RunStop::Raised, 2, tasks: ['php' => [new Task('src/Code.php', $violation)]]); + Assert::equal($outcome, RunOutcome::fromArray(json_decode(json_encode($outcome->toArray(), JSON_THROW_ON_ERROR), associative: true))); +}); + + +test( + 'the tasks are what the fixes of the plan leave in the code as it stands, changing nothing', + function () { + $root = createSteppedProject('steps-tasks'); + $code = <<<'XX' + findTasks($plan); + Assert::same([], $unprocessed); + Assert::same([['src/Date.php', 5]], array_map(fn(Task $task) => [$task->path, $task->violation->line], $tasks['php'])); + Assert::equal( + [new Postponement('src/Date.php', 10, 'dresscode:ignore upgrading.php.deprecatedApi')], + Postponement::findIn($runner->project, $plan->paths), + ); + // a suppression without a reason only puts a task off; and nothing is changed, the fix of `Code.php` included + Assert::same($code, file_get_contents("$root/src/Date.php")); + Assert::notContains('?int', (string) file_get_contents("$root/src/Code.php")); + + // so a run whose fixes leave nothing else is not done either + file_put_contents( + "$root/src/Date.php", + str_replace( + "\treturn strftime('%B');", + "\t// dresscode:ignore upgrading.php.deprecatedApi -- legacy\n\treturn strftime('%B');", + $code, + ), + ); + $outcome = $runner->run($plan); + Assert::same([RunStop::Tasks, null], [$outcome->stop, $outcome->step]); + Assert::equal([new Postponement('src/Date.php', 11, 'dresscode:ignore upgrading.php.deprecatedApi')], $outcome->postponed); + }, +); + + +test('the tasks hold what the check of the whole scope at the end of a run finds and no topic of the plan does', function () { + $root = createSteppedProject('steps-tasks-remaining'); + file_put_contents("$root/src/Date.php", "findTasks($plan); + Assert::same(['deprecations'], array_keys($tasks)); + Assert::same( + [['src/Date.php', 5, 'upgrading.php.deprecatedApi']], + array_map(fn(Task $task) => [$task->path, $task->violation->line, $task->violation->decision], $tasks['deprecations']), + ); +});