diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e83aca31..55ec15a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,10 @@ name: ci build on: - push: - branches: [main] - pull_request: - branches: [main] + pull_request: + push: + branches: + - "main" jobs: build: @@ -23,7 +23,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -37,7 +37,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -68,7 +68,7 @@ jobs: - name: Upload coverage to Codecov if: matrix.coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 90d3ad1a..40f0e7ed 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Ruby uses: ruby/setup-ruby@v1 @@ -29,14 +29,14 @@ jobs: - name: Configure Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 - name: Build documentation working-directory: docs run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}" - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: name: github-pages path: docs/_site diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 980d805b..9abb7eb6 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -13,7 +13,7 @@ jobs: runs-on: "ubuntu-latest" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: "Check for typos" uses: "crate-ci/typos@v1.46.0" diff --git a/docs/available-rules.md b/docs/available-rules.md index a2a9cdd1..8ab531ee 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -63,6 +63,8 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\File`. | `Psr1SymbolsOrSideEffectsRule` | `new Psr1SymbolsOrSideEffectsRule(sourcePaths: ['src/'])` | A file declares symbols or causes side effects, but does not do both. | | `Psr1ValidUtf8Rule` | `new Psr1ValidUtf8Rule(sourcePaths: ['src/'])` | PHP files use valid UTF-8 encoding. | | `Psr1Utf8WithoutBomRule` | `new Psr1Utf8WithoutBomRule(sourcePaths: ['src/'])` | PHP files do not start with a byte order mark. Supports `--fix`. | +| `MustUseLowercaseKeywordConstantRule` | `new MustUseLowercaseKeywordConstantRule(sourcePaths: ['src/'])` | PHP's special keyword constants `true`, `false`, and `null` use their canonical lowercase spelling. Fully qualified forms such as `\TRUE` are preserved as `\true`. Supports `--fix`. | +| `LargeNumericLiteralMustUseSeparatorRule` | `new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000_000, sourcePaths: ['src/'])` | Plain decimal integer and float literals whose magnitude is at least `minimum` (default `1_000_000`) group their integer digits in threes with `_` separators, so `1000500.001` becomes `1_000_500.001`. Hexadecimal, octal, binary, exponent, and already separated literals are ignored. Supports `--fix`. | {: .rule-table } Pass `sourcePaths: null` or omit it to let the rule read PSR-4 paths from `composer.json`. @@ -73,13 +75,18 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. | Rule | Constructor | Checks | |---|---|---| +| `AnonymousClassMayNotHaveEmptyParenthesesRule` | `new AnonymousClassMayNotHaveEmptyParenthesesRule(layer: 'Source')` | Anonymous classes that pass no constructor argument omit the parentheses after `class` (`new class {}`, not `new class () {}`), per [PER Coding Style](https://www.php-fig.org/per/coding-style/#8-anonymous-classes). Supports `--fix` by removing the empty parentheses. | | `ClassConstantNameMustBeUpperCaseRule` | `new ClassConstantNameMustBeUpperCaseRule(layer: 'Domain')` | Class, interface, and trait constants use upper case with underscore separators. Enums are skipped (PER Coding Style recommends PascalCase enum constants). | | `ClassImplementingInterfaceMustHaveSuffixRule` | `new ClassImplementingInterfaceMustHaveSuffixRule(layer: 'HTTP', interface: MiddlewareInterface::class, suffix: 'Middleware')` | Classes implementing a specific interface use the required suffix. | | `ClassNameMustBeStudlyCapsRule` | `new ClassNameMustBeStudlyCapsRule(layer: 'Source')` | Class names use StudlyCaps. | | `ClassNameMustHaveSuffixRule` | `new ClassNameMustHaveSuffixRule(layer: 'Controller', suffix: 'Controller')` | Classes in a layer have the required suffix. | | `ClassNameMustNotHavePrefixRule` | `new ClassNameMustNotHavePrefixRule(layer: 'Model', prefix: 'Model')` | Classes in a layer do not use a forbidden prefix. | +| `EnumCaseNameMustBePascalCaseRule` | `new EnumCaseNameMustBePascalCaseRule(layer: 'Source')` | Enum case names use PascalCase, per [PER Coding Style](https://www.php-fig.org/per/coding-style/#9-enumerations). | +| `EnumConstantMayNotBeProtectedRule` | `new EnumConstantMayNotBeProtectedRule(layer: 'Source')` | Enum constants are not declared `protected` — enums cannot be extended, so `private` is used instead, per [PER Coding Style](https://www.php-fig.org/per/coding-style/#9-enumerations). Supports `--fix` by changing `protected` to `private`. | +| `EnumMethodMayNotBeProtectedRule` | `new EnumMethodMayNotBeProtectedRule(layer: 'Source')` | Enum methods are not declared `protected` — enums cannot be extended, so `private` is used instead, per [PER Coding Style](https://www.php-fig.org/per/coding-style/#9-enumerations). Supports `--fix` by changing `protected` to `private`. | | `ExtendedClassMustBeAbstractOrInstantiatedRule` | `new ExtendedClassMustBeAbstractOrInstantiatedRule(layer: 'Source')` | Classes another scanned class extends are declared `abstract` unless they are also instantiated (`new X`, a `new self`/`new static`/`new parent` resolving to them, a constant class expression such as `new (X::class)` or `new ('App\X')`, or a chained `(new ReflectionClass(X::class))->newInstance*()`). Type hints, `instanceof`, and `::class` keep working on an abstract class, so they do not count. Runtime-fed construction (`new $class` from a parameter, `unserialize()`, container factories) is outside the scanned-code boundary — exclude such factories' targets with rule-scoped `skip()` or `skipRule()`. Supports `--fix` by adding the `abstract` modifier. | | `MaxDependencyCountRule` | `new MaxDependencyCountRule(layer: 'Controller', maxCount: 5)` | Constructor dependency count stays below the configured limit. | +| `MayNotExtendClassRule` | `new MayNotExtendClassRule(layer: 'Domain', class: 'Illuminate\\Database\\Eloquent\\Model')` | Classes in a layer do not extend a forbidden class, directly or through any parent class. | | `MayNotImplementInterfaceRule` | `new MayNotImplementInterfaceRule(layer: 'Domain', interface: JsonSerializable::class)` | Classes in a layer do not implement a forbidden interface. | | `MustBeFinalRule` | `new MustBeFinalRule(layer: 'Domain', classNamePattern: '/Entity$/')` | Matching classes in a layer are declared `final`. Classes extended by another scanned class are skipped (making them `final` would break the child). Supports `--fix`. | | `MustBeUsedInterfaceRule` | `new MustBeUsedInterfaceRule(layer: 'Source')` | Interfaces are implemented by a scanned class (directly or through inheritance), extended by another scanned interface, or referenced as a dependency (type hint, `instanceof`, `::class`, a class-name string, ...). Supports `--fix` by removing the unused interface (and deleting its file when only boilerplate remains). | @@ -95,7 +102,37 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`. `classNamePattern` and `excludePattern` are regular expressions matched against the fully-qualified class name. -`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `ExtendedClassMustBeAbstractOrInstantiatedRule`, `MustBeFinalRule`, `MustBeUsedInterfaceRule`, `MustBeUsedAbstractClassRule`, `MustBeUsedTraitRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically remove PSR-4 mappings for missing directories, normalize invalid PHP opening tags, remove UTF-8 byte order marks, add the `final` or `abstract` class modifier, remove unused interfaces, abstract classes, and traits (deleting their file when only `declare`/`namespace`/`use` boilerplate remains), and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`. +## Fixable Rules + +The following rules implement `Boundwize\StructArmed\Rule\FixableInterface` and can apply their changes when you run `vendor/bin/structarmed analyse --fix`. + +| Rule | Automatic fix | +|---|---| +| `Psr4DirectoryExistsRule` | Removes PSR-4 mappings for missing directories. | +| `Psr1PhpTagsRule` | Normalizes invalid PHP opening tags. | +| `Psr1Utf8WithoutBomRule` | Removes the UTF-8 byte order mark. | +| `MustUseLowercaseKeywordConstantRule` | Lowercases `TRUE`, `FALSE`, and `NULL` keyword constants. | +| `LargeNumericLiteralMustUseSeparatorRule` | Adds `_` separators to large numeric literals. | +| `AnonymousClassMayNotHaveEmptyParenthesesRule` | Removes empty parentheses from anonymous classes that pass no constructor arguments. | +| `ExtendedClassMustBeAbstractOrInstantiatedRule` | Adds the `abstract` modifier to an extended class that is not instantiated. | +| `MustBeFinalRule` | Adds the `final` modifier. | +| `MustBeUsedInterfaceRule` | Removes an unused interface, deleting its file when only boilerplate remains. | +| `MustBeUsedAbstractClassRule` | Removes an unused abstract class, deleting its file when only boilerplate remains. | +| `MustBeUsedTraitRule` | Removes an unused trait, deleting its file when only boilerplate remains. | +| `MustDeclareConstantVisibilityRule` | Adds a missing constant visibility modifier. | +| `MustDeclareMethodVisibilityRule` | Adds a missing method visibility modifier. | +| `MustDeclarePropertyVisibilityRule` | Adds a missing property visibility modifier. | +{: .rule-table } + +## Function Rules + +Namespace: `Boundwize\StructArmed\Rule\Rules\Function_`. + +| Rule | Constructor | Checks | +|---|---|---| +| `MustBeStaticAnonymousFunctionRule` | `new MustBeStaticAnonymousFunctionRule(layer: 'Domain')` | Closures and arrow functions in a layer are declared `static`. Anonymous functions that read `$this` (directly or through a nested closure) are skipped, since a static closure cannot access `$this`. Supports `--fix` by adding the `static` modifier. | +| `MustHaveReturnTypeFunctionRule` | `new MustHaveReturnTypeFunctionRule(layer: 'Helper')` | Named function declarations in a layer declare a return type. | +{: .rule-table } ## Layer Rules diff --git a/docs/cli.md b/docs/cli.md index 4974d51a..1ff7a925 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -26,6 +26,7 @@ vendor/bin/structarmed init --preset=psr15 vendor/bin/structarmed init --preset=mvc vendor/bin/structarmed init --preset=ddd vendor/bin/structarmed init --preset=yagni +vendor/bin/structarmed init --preset=codequality vendor/bin/structarmed init --preset=all ``` diff --git a/docs/configuration.md b/docs/configuration.md index 5462a038..5d95f6c3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -113,4 +113,8 @@ Use [Custom Rules And Presets](../custom-rules-and-presets/) when you want to ad ->withPreset(Preset::PSR4( sourcePaths: ['src/', 'tests/'], // default: read composer.json PSR-4 paths )) + +->withPreset(Preset::CODEQUALITY( + sourcePaths: ['src/', 'tests/'], // default: read composer.json PSR-4 paths +)) ``` diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index a4a47cb2..531f04b1 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -177,6 +177,211 @@ The built-in [YAGNI preset](../presets/) rules follow this pattern: `MustBeUsedI Trade-off: only usage within the scanned paths is known. A class-like used solely by a consumer outside the scan — a vendor package, an unscanned directory, runtime-fed dynamic construction — is reported as if unused. Widen the scan, or use `skipRule()` and skip paths where such consumers exist. +## Reading Other Classes' Layers In A Custom Rule + +A rule only receives the node it is evaluating. When a check depends on the layer of *another* class — the layer a dependency belongs to, for instance — extend `Boundwize\StructArmed\Rule\AbstractLayerAwareRule`. Before any class is evaluated, the analyser injects the map of every scanned class into its protected `$classNodeMap` property, keyed by fully qualified class name, and `getDependencyNode()` looks a class up in it: + +```php +isInLayer('Controller'); + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + foreach ($classNode->dependencies as $dependency) { + $dependencyNode = $this->getDependencyNode($dependency); + + if (! $dependencyNode instanceof ClassNode || $dependencyNode->isInLayer('Application')) { + continue; + } + + return new RuleViolation( + message: sprintf('Controller [%s] must not depend on [%s]', $classNode->className, $dependency), + file: $classNode->file, + line: $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + ); + } + + return null; + } +} +``` + +The base class holds the property, its `injectClassNodeMap()` setter, and the `getDependencyNode()` lookup, so the rule adds nothing but the check itself. A dependency outside the scanned paths — a vendor class, a PHP built-in — has no entry in the map, so `getDependencyNode()` returns null for it; fall back to path or namespace matching for those, or skip them as above. The built-in `MayNotDependOnRule` follows this pattern: it reads the dependency's layers from the map first and falls back to the `toPath` prefix only when the dependency was not scanned. + + +## Analysing Functions, Closures, And Anonymous Classes + +Named functions, closures, arrow functions, and anonymous classes are collected alongside named classes: + +| Node | Represents | Identified by | +| --- | --- | --- | +| `Boundwize\StructArmed\Analyser\FunctionNode` | A named function declaration (`function foo() {}`), global or namespaced | `$functionName` (fully qualified) | +| `Boundwize\StructArmed\Analyser\AnonymousFunctionNode` | A closure (`function () {}`) or arrow function (`fn () => ...`) | `$file` and `$line`, plus `$enclosingClassName` / `$enclosingFunctionName` | +| `Boundwize\StructArmed\Analyser\AnonymousClassNode` | An anonymous class declaration (`new class ... {}`) | `$file` and `$line`, plus `$enclosingClassName` / `$enclosingFunctionName` | + +`FunctionNode` and `AnonymousFunctionNode` both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functionCalls`, `$superglobals`, `$languageConstructs`, `$layer` / `$layers` — plus `$paramCount`, `$hasReturnType`, `$cyclomaticComplexity`, and `$lineCount`. The same query helpers are available: `isInLayer()`, `dependsOn()`, `dependsOnNamespace()`, `callsFunction()`, `usesLanguageConstruct()`, and `accessesSuperglobals()`. A `FunctionNode` also has `shortName()`, `nameStartsWith()`, `nameEndsWith()`, and `nameMatches()`; an `AnonymousFunctionNode` has `$isArrowFunction`, `$isStatic`, `getType()`, and `enclosingScopeName()`. + +A closure declared inside a class or a named function is counted on both nodes: the enclosing `ClassNode` (or `FunctionNode`) keeps seeing everything the closure does, exactly as it sees its own method bodies, and the `AnonymousFunctionNode` reports the closure body on its own. + +Anonymous classes (`new class ... {}`) are collected the same way, as `Boundwize\StructArmed\Analyser\AnonymousClassNode`: identified by `$file` and `$line` plus `$enclosingClassName` / `$enclosingFunctionName` (with `enclosingScopeName()` and `AnonymousClassNode::FILE_SCOPE`), and carrying `$extends`, `$implements`, `$traits`, `$isReadonly`, `$layer` / `$layers`, and `$hasEmptyParentheses` — whether the declaration spells `new class () {}` although it passes no constructor argument. It carries the same body-level facts and query helpers as a `ClassNode` — `$dependencies`, `$functionCalls`, `$superglobals`, and `$languageConstructs`, with `isInLayer()`, `dependsOn()`, `dependsOnNamespace()`, `callsFunction()`, `usesLanguageConstruct()`, and `accessesSuperglobals()` — and its own members: `$methods`, `$constants`, `$properties`, and `constructorParamCount()`. Its parent chain is resolved like a named class's: `$parentClasses` and `$parentInterfaces` hold the direct and transitive parents found in the scanned paths, and `extendsClass()` / `implementsInterface()` answer case-insensitively through that chain, exactly as on a `ClassNode`. An anonymous class never becomes a `ClassNode`; the named class-like or function declaring it keeps seeing its body, exactly as it sees a closure's, while the members belong to the anonymous class alone. + +Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface`, `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`, and/or `Boundwize\StructArmed\Rule\AnonymousClassRuleInterface`. All share the `appliesTo()` / `evaluate()` method names with `RuleInterface`, each typed against its own node kind. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes and anonymous classes are not part of the declarative `ruleset()` layer-dependency check. + +```php +isInLayer($this->layer); + } + + public function evaluate(FunctionNode $functionNode): ?RuleViolation + { + if (! $functionNode->accessesSuperglobals()) { + return null; + } + + return new RuleViolation( + message: sprintf('Function [%s()] must not access superglobals', $functionNode->functionName), + file: $functionNode->file, + line: $functionNode->line, + className: $functionNode->functionName, + layer: $functionNode->layer, + functionName: $functionNode->functionName, + ); + } +} +``` + +An anonymous-function rule looks the same with `AnonymousFunctionNode` in the signatures: + +```php +isInLayer($this->layer); + } + + public function evaluate(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation + { + if (! $anonymousFunctionNode->accessesSuperglobals()) { + return null; + } + + return new RuleViolation( + message: sprintf( + '%s in [%s] must not access superglobals', + $anonymousFunctionNode->getType(), + $anonymousFunctionNode->enclosingScopeName() + ), + file: $anonymousFunctionNode->file, + line: $anonymousFunctionNode->line, + className: $anonymousFunctionNode->enclosingScopeName(), + layer: $anonymousFunctionNode->layer, + ); + } +} +``` + +An anonymous-class rule receives an `AnonymousClassNode` for every anonymous class in the scanned paths. For example, this rule requires anonymous classes in one layer to implement a project-specific marker interface, directly or through the class they extend (`implementsInterface()` walks the resolved parent chain, so `new class extends BaseHandler {}` passes when `BaseHandler` implements the interface): + +```php +isInLayer($this->layer); + } + + public function evaluate(AnonymousClassNode $anonymousClassNode): ?RuleViolation + { + if ($anonymousClassNode->implementsInterface($this->interface)) { + return null; + } + + return new RuleViolation( + message: sprintf( + 'Anonymous class in [%s] must implement [%s]', + $anonymousClassNode->enclosingScopeName(), + $this->interface, + ), + file: $anonymousClassNode->file, + line: $anonymousClassNode->line, + className: $anonymousClassNode->enclosingScopeName(), + layer: $anonymousClassNode->layer, + ); + } +} +``` + +Register it through `Architecture::rule()` like any other custom rule. The analyser invokes it only for anonymous classes because it implements `AnonymousClassRuleInterface`. + +One rule class can also implement several of these interfaces at once; PHP then requires the shared methods to widen the parameter to a union type (for example `appliesTo(FunctionNode|AnonymousFunctionNode|AnonymousClassNode $node): bool`) and the rule branches on the node type inside. + +`RuleViolation::$className` is required, so a function rule passes the function name there (and, optionally, in the dedicated `functionName` field, which the JSON report emits as `"function"`); an anonymous-function or anonymous-class rule passes `enclosingScopeName()`, which is the enclosing class-like or named function, or `FILE_SCOPE` (`'file scope'`) for one in top-level procedural code. + ## Making A Custom Rule Fixable Use `Boundwize\StructArmed\Rule\FixableInterface` when a custom rule can safely rewrite the offending source file. @@ -233,6 +438,54 @@ Built-in rules follow the same pattern: `MustBeFinalRule` returns `AddFinalClass Keep fixers deterministic and narrowly scoped. A failed or skipped fix should return `false` so StructArmed can leave the violation in the report. +## Fixing Syntax The AST Does Not Record + +Some facts a rule reports exist only in the source text, not in any PHP-Parser node. The empty `()` of `new class () {}` is one: both `new class {}` and `new class () {}` parse to the same node with an empty argument list. A visitor that only edits nodes cannot remove those parentheses, and re-printing the whole class to drop them would reformat its body. + +Extend `Boundwize\StructArmed\Rule\Fixer\PhpParser\AbstractTokenAwareVisitor` for such cases. It is a `PhpParser\NodeVisitorAbstract` that holds the tokens the file was parsed into in a protected `$tokens` property: + +```php +/** @var array */ +protected array $tokens = []; + +/** @param array $tokens */ +public function setTokens(array $tokens): void; +``` + +`PhpParserFixerProcessor` calls `setTokens()` before traversing with that visitor. The tokens are the same objects the format-preserving printer copies unchanged code from, so editing a `Token` object's `text` changes the printed file without any node being re-printed. + +```diff ++ use Boundwize\StructArmed\Rule\Fixer\PhpParser\AbstractTokenAwareVisitor; + use PhpParser\Node; +- use PhpParser\NodeVisitorAbstract; + +- final class RemoveSomethingVisitor extends NodeVisitorAbstract ++ final class RemoveSomethingVisitor extends AbstractTokenAwareVisitor + { + public function enterNode(Node $node): ?Node + { + // ... locate the target node ... + ++ for ($index = $node->getStartTokenPos(); $index <= $node->getEndTokenPos(); $index++) { ++ if ($this->tokens[$index]->text === '(' || $this->tokens[$index]->text === ')') { ++ $this->tokens[$index]->text = ''; ++ } ++ } + + return $node; + } + } +``` + +Use `getStartTokenPos()` and `getEndTokenPos()` on the node to find the token range it spans, then walk that range and edit only the tokens the fix targets. Return the node from `enterNode()` unchanged; the fix lives in the tokens, not in the node. + +The built-in `AnonymousClassMayNotHaveEmptyParenthesesRule` follows this shape. It returns `Boundwize\StructArmed\Rule\Fixer\PhpParser\Class_\RemoveAnonymousClassParenthesesVisitor` from `createFixerVisitor()`, and that visitor uses `Boundwize\StructArmed\Util\PhpParser\AnonymousClassParentheses::emptyTokenRange()` to locate the `()` tokens and blank them. + +- Edit token `text` in place; do not replace, add, or remove entries in the token array, since the printer matches tokens to nodes by index. +- Keep the whitespace PHP needs. Blanking a token that separated two words may require leaving a single space in its place. +- Leave a comment inside the edited range alone, or skip the fix when removing the tokens would delete it. +- A rule may still combine a token edit with a node edit in the same visitor; the token edit reaches the output only for nodes the printer keeps unchanged. + ## Custom Presets A custom preset is a class that implements `Boundwize\StructArmed\Preset\PresetInterface`. Inside `apply()`, add the layers and rules you want to reuse. @@ -291,6 +544,10 @@ return Architecture::define() Use `rule()` when one project needs one extra check. -Use a custom `RuleInterface` class when the check itself is new behavior. +Use a custom `RuleInterface` class when the check itself is new behavior; add `FunctionRuleInterface` / `AnonymousFunctionRuleInterface` / `AnonymousClassRuleInterface` when it must also cover named functions, closures, or anonymous classes. + +Extend `AbstractLayerAwareRule` when a rule must know the layer of a class other than the one under evaluation, such as the layer a dependency lives in. Use a custom `PresetInterface` class when several layers and rules should be applied together or reused across repositories. + +Use `AbstractPhpParserFixableRule` with a `PhpParser\NodeVisitor` when a rule can rewrite the offending file; extend `AbstractTokenAwareVisitor` for that visitor when the fix targets punctuation or whitespace PHP-Parser records in no node. diff --git a/docs/presets.md b/docs/presets.md index 0d1dc94c..f3a7295f 100644 --- a/docs/presets.md +++ b/docs/presets.md @@ -19,13 +19,15 @@ StructArmed ships with presets for common PHP standards and architecture styles. | Preset | Rules | |---|---| +| `Preset::PSR4()` | Verifies configured source paths exist in composer.json `autoload` or `autoload-dev` PSR-4 mappings | | `Preset::PSR1()` | Basic Coding Standard checks: PHP tags, valid UTF-8, UTF-8 without BOM, symbols vs side effects, PSR-4 class placement, StudlyCaps class names, upper-case class constants, camelCase methods | -| `Preset::PSR12()` | Extends PSR-1: all methods, constants, and properties must declare explicit visibility | +| `Preset::PSR12()` | Extends PSR-1: PHP keyword constants (`true`, `false`, and `null`) must be lowercase, and all methods, constants, and properties must declare explicit visibility | +| `Preset::PER()` | [PER Coding Style](https://www.php-fig.org/per/coding-style/): extends PSR-12 (and, through it, PSR-1) and adds PascalCase enum case names, no `protected` enum methods or constants, and no empty `()` on anonymous classes that pass no constructor argument (`new class {}`, not `new class () {}`); the anonymous-class and `protected` rules support `--fix` | | `Preset::PSR15()` | `*Middleware` classes must implement PSR-15 `MiddlewareInterface`; `*Handler` classes must implement PSR-15 `RequestHandlerInterface`; StructArmed also enforces matching `Middleware`/`Handler` suffixes for implementations of those interfaces | -| `Preset::PSR4()` | Verifies configured source paths exist in composer.json `autoload` or `autoload-dev` PSR-4 mappings | -| `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions | -| `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules | +| `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions, including keeping Doctrine ORM repository inheritance out of the Domain layer | +| `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules, return types for helper functions | | `Preset::YAGNI()` | Speculative-abstraction cleanup: interfaces must be implemented by a class or extended by another interface, abstract classes must be extended, traits must be used, and extended classes that are never instantiated must be abstract — a dependency reference (type hint, `instanceof`, `::class`, static call, a class-name string, ...) also counts as usage within the scanned paths, while only instantiation (`new X`, `new self`/`static`/`parent`, or a constant class expression such as `new (X::class)`) keeps an extended class concrete. All rules support `--fix`, removing the unused declaration or adding the `abstract` modifier | +| `Preset::CODEQUALITY()` | General readability conventions independent of any architecture style: closures and arrow functions that do not read `$this` must be declared `static`, and plain decimal numeric literals of `1_000_000` or more must group their digits with `_` separators (`1000500` becomes `1_000_500`). Both rules support `--fix`. Tune the literal threshold with `replaceRule(CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR, new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000))` | ## Initialize Presets @@ -33,10 +35,12 @@ StructArmed ships with presets for common PHP standards and architecture styles. vendor/bin/structarmed init --preset=psr4 vendor/bin/structarmed init --preset=psr1 vendor/bin/structarmed init --preset=psr12 +vendor/bin/structarmed init --preset=per vendor/bin/structarmed init --preset=psr15 vendor/bin/structarmed init --preset=mvc vendor/bin/structarmed init --preset=ddd vendor/bin/structarmed init --preset=yagni +vendor/bin/structarmed init --preset=codequality vendor/bin/structarmed init --preset=all ``` @@ -48,10 +52,12 @@ return Architecture::define() Preset::PSR4(), Preset::PSR1(), Preset::PSR12(), + Preset::PER(), Preset::PSR15(), Preset::MVC(), Preset::DDD(), Preset::YAGNI(), + Preset::CODEQUALITY(), ); ``` diff --git a/docs/quick-start.md b/docs/quick-start.md index faafd712..81e70b46 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -33,7 +33,7 @@ vendor/bin/structarmed init --preset=psr4 # Enforce basic coding standard rules vendor/bin/structarmed init --preset=psr1 -# PSR-12 extends PSR-1 with explicit member visibility checks +# PSR-12 extends PSR-1 with lowercase keyword constants and explicit member visibility checks vendor/bin/structarmed init --preset=psr12 # PSR-15 middleware and request handler interface checks @@ -48,6 +48,9 @@ vendor/bin/structarmed init --preset=ddd # Remove speculative abstractions: unimplemented interfaces, unextended abstract classes, unused traits vendor/bin/structarmed init --preset=yagni +# Static closures and digit separators in large numeric literals +vendor/bin/structarmed init --preset=codequality + # Enable every preset at once vendor/bin/structarmed init --preset=all ``` diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index c3103365..d5e73326 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -4,8 +4,8 @@ namespace Boundwize\StructArmed\Analyser; -use Boundwize\StructArmed\Analyser\ClassNodeExtractor; -use Boundwize\StructArmed\Analyser\Parallel\ParallelClassNodeExtractor; +use Boundwize\StructArmed\Analyser\AnalysisNodeExtractor; +use Boundwize\StructArmed\Analyser\Parallel\ParallelAnalysisNodeExtractor; use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Cache\AnalysisResultCache; use Boundwize\StructArmed\Composer\Psr4PathResolver; @@ -13,11 +13,14 @@ use Boundwize\StructArmed\File\SkipPathMatcher; use Boundwize\StructArmed\LayerResolver\ChainLayerResolver; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; +use Boundwize\StructArmed\Rule\AbstractLayerAwareRule; +use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface; +use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\ComposerJsonRuleInterface; use Boundwize\StructArmed\Rule\ExtendedClassAwareRuleInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\FixableInterface; -use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; +use Boundwize\StructArmed\Rule\FunctionRuleInterface; use Boundwize\StructArmed\Rule\MultipleProjectRuleViolationInterface; use Boundwize\StructArmed\Rule\MultipleRuleViolationInterface; use Boundwize\StructArmed\Rule\ProjectRuleInterface; @@ -33,6 +36,7 @@ use function array_key_exists; use function array_keys; use function array_merge; +use function array_push; use function array_unique; use function array_values; use function count; @@ -52,7 +56,7 @@ public function __construct( string $basePath = '', private ?AnalysisResultCache $analysisResultCache = null, - private string $classNodeCacheNamespace = '', + private string $analysisNodeCacheNamespace = '', private PhpFileCollector $phpFileCollector = new PhpFileCollector(), ) { $this->basePath = $basePath !== '' ? $basePath : (string) getcwd(); @@ -77,24 +81,46 @@ public function analyse( $ruleSkipPaths = $architecture->getRuleSkipPaths(); $skippedRuleKeys = $this->skippedRuleKeyMap($architecture->getSkippedRuleKeys()); - $projectRuleViolations = []; - $fileAnalysisRules = []; - $classRules = []; - $layerAwareRules = []; - $hasExtendedClassAwareRule = false; - $hasUsedInterfaceAwareRule = false; - $hasUsedTraitAwareRule = false; + $projectRuleViolations = []; + $fileAnalysisRules = []; + $nodeRules = []; + $classNodeRules = []; + $functionNodeRules = []; + $anonymousFunctionNodeRules = []; + $anonymousClassNodeRules = []; + $layerAwareRules = []; + $hasExtendedClassAwareRule = false; + $hasUsedInterfaceAwareRule = false; + $hasUsedTraitAwareRule = false; foreach ($rules as $key => $rule) { if (array_key_exists($key, $skippedRuleKeys)) { continue; } + // Grouped per node kind here so the evaluation loop below matches + // rules to nodes without re-checking interfaces per node × rule. if ($rule instanceof RuleInterface) { - $classRules[$key] = $rule; + $nodeRules[$key] = $rule; + $classNodeRules[$key] = $rule; } - if ($rule instanceof LayerAwareRuleInterface) { + if ($rule instanceof FunctionRuleInterface) { + $nodeRules[$key] = $rule; + $functionNodeRules[$key] = $rule; + } + + if ($rule instanceof AnonymousFunctionRuleInterface) { + $nodeRules[$key] = $rule; + $anonymousFunctionNodeRules[$key] = $rule; + } + + if ($rule instanceof AnonymousClassRuleInterface) { + $nodeRules[$key] = $rule; + $anonymousClassNodeRules[$key] = $rule; + } + + if ($rule instanceof AbstractLayerAwareRule) { $layerAwareRules[] = $rule; } @@ -144,7 +170,7 @@ public function analyse( $files ??= $this->filesForAnalysis($architecture, $scanPaths, $layers); $withFileAnalysis = $fileAnalysisRules !== []; - $extractionResult = $this->collectClassNodes( + $extractionResult = $this->collectAnalysisNodes( $files, $progressHandler, $layers, @@ -154,7 +180,7 @@ public function analyse( $withFileAnalysis, ); $classNodes = $extractionResult->classNodes; - $classNodes = $this->withRecursiveParents($classNodes); + $classNodes = $this->withRecursiveParents($classNodes, $extractionResult->anonymousClassNodes); if ($hasExtendedClassAwareRule || $hasUsedInterfaceAwareRule || $hasUsedTraitAwareRule) { $this->markClassLikeUsage( @@ -193,6 +219,8 @@ className: $violation->className, methodName: $violation->methodName, constantName: $violation->constantName, propertyName: $violation->propertyName, + functionName: $violation->functionName, + numericLiteral: $violation->numericLiteral, )); } } @@ -216,7 +244,7 @@ className: $violation->className, } $globalSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $globalSkipPaths); - $ruleSkipMatchers = $this->ruleSkipMatchers($classRules, $ruleSkipPaths); + $ruleSkipMatchers = $this->ruleSkipMatchers($nodeRules, $ruleSkipPaths); $rulesetSkipPaths = $architecture->getRulesetSkipPaths(); $rulesetSkipPathMatcher = SkipPathMatcher::compile($this->basePath, $rulesetSkipPaths); $rulesetViolationCollection = new RuleViolationCollection(); @@ -231,55 +259,29 @@ className: $violation->className, $resolvedInheritedDependencies = []; - foreach ($layerAwareRules as $rule) { - $rule->injectClassNodeMap($classDependencyMaps['classNodeMap']); + foreach ($layerAwareRules as $layerAwareRule) { + $layerAwareRule->injectClassNodeMap($classDependencyMaps['classNodeMap']); } - foreach ($classNodes as $classNode) { - if ($globalSkipPathMatcher->isSkipped($classNode->file)) { - continue; - } - - foreach ($classRules as $key => $rule) { - if (isset($ruleSkipMatchers[$key]) && $ruleSkipMatchers[$key]->isSkipped($classNode->file)) { - continue; - } - - if (! $rule->appliesTo($classNode)) { - continue; - } - - if ($rule instanceof MultipleRuleViolationInterface) { - $violations = $rule->evaluateAll($classNode); - } else { - $violation = $rule->evaluate($classNode); - if (! $violation instanceof RuleViolation) { - continue; - } - - $violations = [$violation]; - } - - $isFixable = $rule instanceof FixableInterface; - - foreach ($violations as $violation) { - // Inject the rule key into the violation - $ruleViolationCollection->add(new RuleViolation( - message: $violation->message, - file: $violation->file, - line: $violation->line, - className: $violation->className, - layer: $violation->layer, - ruleKey: $key, - fixable: $isFixable, - methodName: $violation->methodName, - constantName: $violation->constantName, - propertyName: $violation->propertyName, - )); - } - } + // Function-likes and anonymous classes are not part of the class + // hierarchy, so they take no part in the declarative ruleset below; a + // rule only sees the node kind whose interface it implements, so each + // node collection is paired with the rules grouped for its kind above. + $this->evaluateNodeRules( + [ + [$classNodes, $classNodeRules], + [$extractionResult->functionNodes, $functionNodeRules], + [$extractionResult->anonymousFunctionNodes, $anonymousFunctionNodeRules], + [$extractionResult->anonymousClassNodes, $anonymousClassNodeRules], + ], + $globalSkipPathMatcher, + $ruleSkipMatchers, + $ruleViolationCollection + ); - if (! $hasRuleset) { + // Declarative ruleset dependency checks, per class node. + foreach ($hasRuleset ? $classNodes : [] as $classNode) { + if ($globalSkipPathMatcher->isSkipped($classNode->file)) { continue; } @@ -372,6 +374,78 @@ className: $classNode->className, return $ruleViolationCollection; } + /** + * Evaluates each node collection against the rules grouped for its node + * kind, in a single evaluation implementation: all four rule interfaces + * share the appliesTo()/evaluate() method names, and a rule only receives + * the node kind whose interface it implements. + * + * @param list, 1: array}> $nodeGroups + * @param array $ruleSkipMatchers + * @phpstan-param list|list|list|list, + * 1: array + * }> $nodeGroups + */ + private function evaluateNodeRules( + array $nodeGroups, + SkipPathMatcher $globalSkipPathMatcher, + array $ruleSkipMatchers, + RuleViolationCollection $ruleViolationCollection + ): void { + foreach ($nodeGroups as [$nodes, $rules]) { + if ($rules === []) { + continue; + } + + foreach ($nodes as $node) { + if ($globalSkipPathMatcher->isSkipped($node->file)) { + continue; + } + + foreach ($rules as $key => $rule) { + if (isset($ruleSkipMatchers[$key]) && $ruleSkipMatchers[$key]->isSkipped($node->file)) { + continue; + } + + if (! $rule->appliesTo($node)) { + continue; + } + + if ($rule instanceof MultipleRuleViolationInterface && $node instanceof ClassNode) { + $violations = $rule->evaluateAll($node); + } else { + $violation = $rule->evaluate($node); + if (! $violation instanceof RuleViolation) { + continue; + } + + $violations = [$violation]; + } + + $isFixable = $rule instanceof FixableInterface; + foreach ($violations as $violation) { + $ruleViolationCollection->add(new RuleViolation( + message: $violation->message, + file: $violation->file, + line: $violation->line, + className: $violation->className, + layer: $violation->layer, + ruleKey: $key, + fixable: $isFixable, + methodName: $violation->methodName, + constantName: $violation->constantName, + propertyName: $violation->propertyName, + functionName: $violation->functionName, + numericLiteral: $violation->numericLiteral, + )); + } + } + } + } + } + /** * Expand `+LayerName` references in a ruleset into their concrete allowed layers. * @@ -479,16 +553,16 @@ private function isSourceSynthesised(Architecture $architecture): bool } /** - * @param array $classRules + * @param array $nodeRules Node rules of every kind, by key * @param array> $ruleSkipPaths * @return array */ - private function ruleSkipMatchers(array $classRules, array $ruleSkipPaths): array + private function ruleSkipMatchers(array $nodeRules, array $ruleSkipPaths): array { $ruleSkipMatchers = []; foreach ($ruleSkipPaths as $key => $skipPaths) { - if (! isset($classRules[$key]) || $skipPaths === []) { + if (! isset($nodeRules[$key]) || $skipPaths === []) { continue; } @@ -687,6 +761,38 @@ private function dependenciesForInheritanceDependency( return $resolvedDependencies; } + /** + * A node's own inheritance-clause names (and the imports that exist for + * them) are structural relations, not value references. Excluding them + * keeps "referenced" meaningful for the unresolved dynamic instantiation + * check: a class extended by a child is not thereby a possible + * `new $class` target. The usage-aware deletion rules are unaffected — + * each combines this flag with its structural extended/implemented/trait + * marking. + * + * @param list $dependencies + * @param array $clauseNames The node's own name, if any, and its extends, implements, and traits + * @param array $used + */ + private function markDependenciesUsed(array $dependencies, array $clauseNames, array &$used): void + { + $excludedKeys = []; + + foreach ($clauseNames as $clauseName) { + if ($clauseName !== null) { + $excludedKeys[strtolower($clauseName)] = true; + } + } + + foreach ($dependencies as $dependency) { + $dependencyKey = strtolower($dependency); + + if (! isset($excludedKeys[$dependencyKey])) { + $used[$dependencyKey] = true; + } + } + } + /** * Collect and apply class-like usage flags with one collection pass and one * application pass over the class nodes. Extended classes use the recursive @@ -722,36 +828,22 @@ private function markClassLikeUsage( $used[strtolower($trait)] = true; } - // A node's own inheritance-clause names (and the imports that - // exist for them) are structural relations, not value references. - // Excluding them keeps "referenced" meaningful for the unresolved - // dynamic instantiation check below: a class extended by a child - // is not thereby a possible `new $class` target. The usage-aware - // deletion rules are unaffected — each combines this flag with its - // structural extended/implemented/trait marking. - $excludedKeys = [strtolower($classNode->className) => true]; - - if ($classNode->extends !== null) { - $excludedKeys[strtolower($classNode->extends)] = true; - } - - foreach ([$classNode->implements, $classNode->interfaceExtends, $classNode->traits] as $clauseNames) { - foreach ($clauseNames as $clauseName) { - $excludedKeys[strtolower($clauseName)] = true; - } - } - - foreach ($classNode->dependencies as $dependency) { - $dependencyKey = strtolower($dependency); - - if (! isset($excludedKeys[$dependencyKey])) { - $used[$dependencyKey] = true; - } - } + $this->markDependenciesUsed( + $classNode->dependencies, + [ + $classNode->className, + $classNode->extends, + ...$classNode->implements, + ...$classNode->interfaceExtends, + ...$classNode->traits, + ], + $used, + ); } // Anonymous classes have no ClassNode of their own, so their inheritance - // and trait-use relationships are tracked separately. + // and trait-use relationships, and their body references, are tracked + // separately. foreach ($extractionResult->anonymousClassNodes as $anonymousClassNode) { if ($markExtended && $anonymousClassNode->extends !== null) { $extended[strtolower($anonymousClassNode->extends)] = true; @@ -766,11 +858,17 @@ private function markClassLikeUsage( foreach ($anonymousClassNode->traits as $trait) { $used[strtolower($trait)] = true; } + + $this->markDependenciesUsed( + $anonymousClassNode->dependencies, + [$anonymousClassNode->extends, ...$anonymousClassNode->implements, ...$anonymousClassNode->traits], + $used, + ); } - // References made outside any named class-like scope — procedural - // functions, top-level statements, top-level anonymous class bodies — - // have no ClassNode either, so they are tracked per file. + // References made outside any class-like scope — procedural functions + // and top-level statements — have no ClassNode either, so they are + // tracked per file. foreach ($extractionResult->fileReferences as $references) { foreach ($references as $reference) { $used[strtolower($reference)] = true; @@ -782,7 +880,7 @@ private function markClassLikeUsage( foreach ($extractionResult->fileInstantiations as $instantiations) { foreach ($instantiations as $instantiation) { - $deferredMarker = ClassCollector::parseDeferredInstantiationMarker($instantiation); + $deferredMarker = AnalysisNodeCollector::parseDeferredInstantiationMarker($instantiation); if ($deferredMarker === null) { $instantiated[strtolower($instantiation)] = true; @@ -977,10 +1075,11 @@ private function collectTraitUsers( } /** - * @param list $classNodes + * @param list $classNodes + * @param list $anonymousClassNodes * @return list */ - private function withRecursiveParents(array $classNodes): array + private function withRecursiveParents(array $classNodes, array $anonymousClassNodes): array { $parentClassMap = []; $parentInterfaceMap = []; @@ -1019,13 +1118,32 @@ private function withRecursiveParents(array $classNodes): array $classNode->setRecursiveParents($result['classes'], $result['interfaces']); } + // An anonymous class is never a parent, so it is absent from the maps and + // starts the DFS from its own `extends`/`implements` clauses instead. + foreach ($anonymousClassNodes as $anonymousClassNode) { + if ($anonymousClassNode->extends === null && $anonymousClassNode->implements === []) { + continue; + } + + $cycleDetected = false; + $result = $this->collectRecursiveParents( + $anonymousClassNode->extends !== null ? [$anonymousClassNode->extends] : [], + $anonymousClassNode->implements, + $parentClassMap, + $parentInterfaceMap, + $parentsCache, + [], + $cycleDetected + ); + + $anonymousClassNode->setRecursiveParents($result['classes'], $result['interfaces']); + } + return $classNodes; } /** - * Single DFS that collects both ancestor classes and transitively implemented/extended - * interfaces in one pass, avoiding the double traversal of the parent-class chain that - * the previous two-method approach required. + * Cached, name-keyed entry point to the parent-chain DFS for a scanned class-like. * * @param array> $parentClassMap * @param array> $parentInterfaceMap @@ -1045,11 +1163,54 @@ private function recursiveParents( return $cache[$classNameKey]; } + $hasCycle = false; + $result = $this->collectRecursiveParents( + $parentClassMap[$classNameKey] ?? [], + $parentInterfaceMap[$classNameKey] ?? [], + $parentClassMap, + $parentInterfaceMap, + $cache, + $seen, + $hasCycle + ); + + if (! $hasCycle) { + $cache[$classNameKey] = $result; + } + + $cycleDetected = $cycleDetected || $hasCycle; + + return $result; + } + + /** + * Single DFS that collects both ancestor classes and transitively implemented/extended + * interfaces in one pass, avoiding the double traversal of the parent-class chain that + * the previous two-method approach required. Seeded with a node's direct parents so an + * anonymous class, which has no name to look up in the maps, resolves its chain the + * same way a named class does. + * + * @param string[] $parentClasses + * @param string[] $parentInterfaces + * @param array> $parentClassMap + * @param array> $parentInterfaceMap + * @param array, interfaces: list}> $cache + * @param array $seen + * @return array{classes: list, interfaces: list} + */ + private function collectRecursiveParents( + array $parentClasses, + array $parentInterfaces, + array $parentClassMap, + array $parentInterfaceMap, + array &$cache, + array $seen, + bool &$hasCycle + ): array { $classesSet = []; $interfacesSet = []; - $hasCycle = false; - foreach ($parentClassMap[$classNameKey] ?? [] as $parentClass) { + foreach ($parentClasses as $parentClass) { $parentClassKey = strtolower($parentClass); if (isset($seen[$parentClassKey])) { @@ -1079,7 +1240,7 @@ private function recursiveParents( $hasCycle = $hasCycle || $childHasCycle; } - foreach ($parentInterfaceMap[$classNameKey] ?? [] as $parentInterface) { + foreach ($parentInterfaces as $parentInterface) { $parentInterfaceKey = strtolower($parentInterface); if (isset($seen[$parentInterfaceKey])) { @@ -1105,18 +1266,10 @@ private function recursiveParents( $hasCycle = $hasCycle || $childHasCycle; } - $result = [ + return [ 'classes' => array_keys($classesSet), 'interfaces' => array_keys($interfacesSet), ]; - - if (! $hasCycle) { - $cache[$classNameKey] = $result; - } - - $cycleDetected = $cycleDetected || $hasCycle; - - return $result; } /** @@ -1128,7 +1281,7 @@ private function recursiveParents( * excludePattern: string|list|null * }> $layerPatterns */ - private function collectClassNodes( + private function collectAnalysisNodes( array $files, ?ProgressHandlerInterface $progressHandler, array $layers, @@ -1137,70 +1290,38 @@ private function collectClassNodes( ?AnalyserOptions $analyserOptions = null, bool $withFileAnalysis = true, ): ExtractionResult { - $classNodes = []; - $fileAnalyses = []; - $anonymousClassNodes = []; - $fileReferences = []; - $fileInstantiations = []; - $filesToParse = []; + $classNodes = []; + $fileAnalyses = []; + $anonymousClassNodes = []; + $fileReferences = []; + $fileInstantiations = []; + $functionNodes = []; + $anonymousFunctionNodes = []; + $filesToParse = []; foreach ($files as $file) { - if ($withFileAnalysis) { - $cachedResult = $this->analysisResultCache?->loadClassNodesWithFileAnalysis( + $cachedResult = $withFileAnalysis + ? $this->analysisResultCache?->loadAnalysisNodesWithFileAnalysis( $file, - $this->classNodeCacheNamespace - ); - - if ($cachedResult === null) { - $filesToParse[] = $file; - continue; - } - - foreach ($cachedResult['classNodes'] as $cachedClassNode) { - $classNodes[] = $cachedClassNode; - } - - foreach ($cachedResult['anonymousClassNodes'] as $cachedAnonymousClassNode) { - $anonymousClassNodes[] = $cachedAnonymousClassNode; - } - - if ($cachedResult['fileReferences'] !== []) { - $fileReferences[$file] = $cachedResult['fileReferences']; - } - - if ($cachedResult['fileInstantiations'] !== []) { - $fileInstantiations[$file] = $cachedResult['fileInstantiations']; - } - - $fileAnalyses[$file] = $cachedResult['fileAnalysis']; - - continue; - } - - $cachedResult = $this->analysisResultCache?->loadClassNodes( - $file, - $this->classNodeCacheNamespace, - ); + $this->analysisNodeCacheNamespace + ) + : $this->analysisResultCache?->loadAnalysisNodes($file, $this->analysisNodeCacheNamespace); if ($cachedResult === null) { $filesToParse[] = $file; continue; } - foreach ($cachedResult['classNodes'] as $cachedClassNode) { - $classNodes[] = $cachedClassNode; - } - - foreach ($cachedResult['anonymousClassNodes'] as $cachedAnonymousClassNode) { - $anonymousClassNodes[] = $cachedAnonymousClassNode; - } + array_push($classNodes, ...$cachedResult['classNodes']); + array_push($anonymousClassNodes, ...$cachedResult['anonymousClassNodes']); + array_push($functionNodes, ...$cachedResult['functionNodes']); + array_push($anonymousFunctionNodes, ...$cachedResult['anonymousFunctionNodes']); - if ($cachedResult['fileReferences'] !== []) { - $fileReferences[$file] = $cachedResult['fileReferences']; - } + $fileReferences[$file] = $cachedResult['fileReferences']; + $fileInstantiations[$file] = $cachedResult['fileInstantiations']; - if ($cachedResult['fileInstantiations'] !== []) { - $fileInstantiations[$file] = $cachedResult['fileInstantiations']; + if (isset($cachedResult['fileAnalysis'])) { + $fileAnalyses[$file] = $cachedResult['fileAnalysis']; } } @@ -1215,77 +1336,44 @@ private function collectClassNodes( $anonymousClassNodes, $fileReferences, $fileInstantiations, + $functionNodes, + $anonymousFunctionNodes, ); } $options = $analyserOptions ?? AnalyserOptions::parallel(); if ($options->isParallel()) { - $parsedResult = (new ParallelClassNodeExtractor( + // Workers write their own files' cache payloads while other workers are + // still parsing, instead of the coordinator doing it serially afterwards. + $parsedResult = (new ParallelAnalysisNodeExtractor( $this->basePath, $layers, $layerPatterns, $options->workerCount, $this->analysisResultCache?->getCacheDirectory(), + $this->analysisResultCache, + $this->analysisNodeCacheNamespace, ))->extract($filesToParse, $progressHandler, $withFileAnalysis); } else { - $parsedResult = (new ClassNodeExtractor($chainLayerResolver))->extract( - $filesToParse, - $progressHandler, - $withFileAnalysis, - ); - } - - $classNodesByFile = array_fill_keys($filesToParse, []); - foreach ($parsedResult->classNodes as $parsedClassNode) { - $classNodes[] = $parsedClassNode; - - if (isset($classNodesByFile[$parsedClassNode->file])) { - $classNodesByFile[$parsedClassNode->file][] = $parsedClassNode; - } - } - - $anonymousClassNodesByFile = array_fill_keys($filesToParse, []); - foreach ($parsedResult->anonymousClassNodes as $parsedAnonymousClassNode) { - $anonymousClassNodes[] = $parsedAnonymousClassNode; - - if (isset($anonymousClassNodesByFile[$parsedAnonymousClassNode->file])) { - $anonymousClassNodesByFile[$parsedAnonymousClassNode->file][] = $parsedAnonymousClassNode; - } - } - - foreach ($parsedResult->fileAnalyses as $file => $fileAnalysis) { - $fileAnalyses[$file] = $fileAnalysis; - } - - foreach ($parsedResult->fileReferences as $file => $parsedFileReferences) { - $fileReferences[$file] = $parsedFileReferences; - } - - foreach ($parsedResult->fileInstantiations as $file => $parsedFileInstantiations) { - $fileInstantiations[$file] = $parsedFileInstantiations; - } - - foreach ($classNodesByFile as $fileToParse => $fileClassNodes) { - $this->analysisResultCache?->storeClassNodes( - $fileToParse, - $this->classNodeCacheNamespace, - $fileClassNodes, - $fileAnalyses[$fileToParse] ?? null, - $anonymousClassNodesByFile[$fileToParse] ?? [], - $fileReferences[$fileToParse] ?? [], - $fileInstantiations[$fileToParse] ?? [], - ); + $parsedResult = (new AnalysisNodeExtractor( + $chainLayerResolver, + analysisResultCache: $this->analysisResultCache, + analysisNodeCacheNamespace: $this->analysisNodeCacheNamespace, + ))->extract($filesToParse, $progressHandler, $withFileAnalysis); } $progressHandler?->finish(); + // Cached nodes first, then the freshly parsed ones. return new ExtractionResult( - $classNodes, - $fileAnalyses, - $anonymousClassNodes, - $fileReferences, - $fileInstantiations, + classNodes: [...$classNodes, ...$parsedResult->classNodes], + fileAnalyses: $fileAnalyses + $parsedResult->fileAnalyses, + anonymousClassNodes: [...$anonymousClassNodes, ...$parsedResult->anonymousClassNodes], + fileReferences: $fileReferences + $parsedResult->fileReferences, + fileInstantiations: $fileInstantiations + $parsedResult->fileInstantiations, + functionNodes: [...$functionNodes, ...$parsedResult->functionNodes], + anonymousFunctionNodes: [...$anonymousFunctionNodes, ...$parsedResult->anonymousFunctionNodes], ); } diff --git a/src/Analyser/AnalysisNodeCollector.php b/src/Analyser/AnalysisNodeCollector.php new file mode 100644 index 00000000..bc5c0421 --- /dev/null +++ b/src/Analyser/AnalysisNodeCollector.php @@ -0,0 +1,1697 @@ + true, + '_POST' => true, + '_REQUEST' => true, + '_SESSION' => true, + '_COOKIE' => true, + '_SERVER' => true, + '_ENV' => true, + '_FILES' => true, + 'GLOBALS' => true, + ]; + + private const KEYWORD_CONSTANTS = [ + 'true' => true, + 'false' => true, + 'null' => true, + ]; + + /** + * A string value shaped like a (possibly namespaced) class name, e.g. + * 'App\Contract' or 'stdClass'. Such values can reach `new $class` or + * `instanceof $class` at runtime, so they count as references. + */ + private const CLASS_LIKE_STRING_PATTERN = + '/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*+(?:\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*+)*+$/'; + + /** + * Method names of the ReflectionClass object-construction APIs. Calling + * one chained on a `new ReflectionClass()` receiver + * instantiates the reflected class. + */ + private const REFLECTION_CONSTRUCTION_METHODS = [ + 'newinstance' => true, + 'newinstanceargs' => true, + 'newinstancewithoutconstructor' => true, + 'newlazyghost' => true, + 'newlazyproxy' => true, + ]; + + /** + * Node classes counted as cyclomatic-complexity branches. The parser only + * ever instantiates these exact classes, so a single ::class hash lookup + * replaces an instanceof chain on the per-node hot path. + */ + private const COMPLEXITY_BRANCH_NODES = [ + If_::class => true, + ElseIf_::class => true, + For_::class => true, + Foreach_::class => true, + While_::class => true, + Do_::class => true, + Case_::class => true, + Catch_::class => true, + Ternary::class => true, + BooleanAnd::class => true, + BooleanOr::class => true, + LogicalAnd::class => true, + LogicalOr::class => true, + Coalesce::class => true, + AssignCoalesce::class => true, + NullsafeMethodCall::class => true, + NullsafePropertyFetch::class => true, + MatchArm::class => true, + ]; + + /** + * Node classes that map to a fixed language-construct name. Exit_ and + * Include_ are handled separately: their names depend on node data. + */ + private const LANGUAGE_CONSTRUCT_NODES = [ + Echo_::class => 'echo', + Print_::class => 'print', + Isset_::class => 'isset', + Empty_::class => 'empty', + Unset_::class => 'unset', + Eval_::class => 'eval', + List_::class => 'list', + ]; + + /** + * Class-like member statements collected on enter, see collectMember(). + * EnumCase is collected on leave instead: its value expression may hold + * class names that the NameResolver only resolves on entering the + * expression's own nodes, after this visitor has entered the case. + */ + private const MEMBER_NODES = [ + Property::class => true, + ClassConst::class => true, + TraitUse::class => true, + ]; + + /** + * Every node class enterNode() acts on: the scope-tracking statements, + * function-likes, and everything collectNodeAnalysis() records. The + * parser only ever instantiates these exact classes, so a single ::class + * hash lookup lets the large majority of nodes (identifiers, arguments, + * most scalars, assignments, ...) return before any instanceof check. + */ + private const ENTER_NODES = self::COMPLEXITY_BRANCH_NODES + + self::LANGUAGE_CONSTRUCT_NODES + + self::MEMBER_NODES + + [ + Namespace_::class => true, + Use_::class => true, + GroupUse::class => true, + Function_::class => true, + Class_::class => true, + Interface_::class => true, + Trait_::class => true, + Enum_::class => true, + ClassMethod::class => true, + Closure::class => true, + ArrowFunction::class => true, + String_::class => true, + Int_::class => true, + Float_::class => true, + FullyQualified::class => true, + ConstFetch::class => true, + Variable::class => true, + FuncCall::class => true, + Exit_::class => true, + Include_::class => true, + ]; + + /** + * Every node class leaveNode() acts on, see ENTER_NODES. + */ + private const LEAVE_NODES = [ + Closure::class => true, + ArrowFunction::class => true, + New_::class => true, + MethodCall::class => true, + NullsafeMethodCall::class => true, + ClassMethod::class => true, + EnumCase::class => true, + Function_::class => true, + Class_::class => true, + Interface_::class => true, + Trait_::class => true, + Enum_::class => true, + ]; + + /** @var list */ + private array $classNodes = []; + + /** @var list */ + private array $anonymousClassNodes = []; + + /** @var list */ + private array $functionNodes = []; + + /** @var list */ + private array $anonymousFunctionNodes = []; + + /** @var array> */ + private array $fileReferences = []; + + /** @var array */ + private array $currentFileReferences = []; + + /** + * Separator of a deferred instantiation marker, `@`, + * recorded when `new self()`, `new static()`, or `new parent()` cannot be + * resolved to a class name until every class has been collected. The `@` + * cannot occur in a class name, so a marker never collides with a real + * instantiation target. + * + * @see deferredInstantiationMarker() + * @see parseDeferredInstantiationMarker() + */ + private const DEFERRED_MARKER_SEPARATOR = '@'; + + /** @var array> */ + private array $fileInstantiations = []; + + /** @var array */ + private array $currentFileInstantiations = []; + + /** + * `true`, `false`, and `null` fetches of the current file whose spelling is + * not the canonical lowercase, as [line, spelling as written]; a leading + * `\` marks a fully qualified form. Reset per file instead of in + * afterTraverse() so the extractor can read them once traversal finishes. + * + * @var list + */ + private array $nonCanonicalKeywordConstants = []; + + /** + * Numeric literals of the current file, as [line, spelling as written, + * evaluated value]. Reset per file so the extractor can read them once + * traversal finishes. + * + * @var list + */ + private array $numericLiterals = []; + + private readonly ConstExprEvaluator $constExprEvaluator; + + /** + * Stack of class-likes currently being entered, so `new self`, + * `new static`, and `new parent` instantiations can be resolved to the + * class names (or deferred markers) they target. Anonymous classes have + * no name to resolve self/static to, but their `extends` still resolves + * `parent`. + * + * @var list + */ + private array $activeClassLikeScopes = []; + + private string $currentFile = ''; + + /** @var array */ + private array $currentTokens = []; + + /** @var array */ + private array $currentNamespaceUses = []; + + /** @var ClassLike[] */ + private array $fileClassLikes = []; + + /** + * The named scopes declaring each anonymous class left in the current + * file — innermost class-like name, innermost function name — keyed by + * the class node's object id and read once its node is built. + * + * @var array + */ + private array $anonymousClassEnclosingNames = []; + + /** @var array */ + private array $fileFunctions = []; + + /** @var array */ + private array $classLikeAnalysis = []; + + /** @var list */ + private array $activeClassLikeAnalyses = []; + + /** + * Cyclomatic complexity of each tracked method currently being entered, + * innermost last. A branch node increments every entry: a method nested + * through an anonymous class still adds to its enclosing method. + * + * @var list + */ + private array $activeMethodComplexities = []; + + /** + * Names of the class-likes currently being entered, innermost last; an + * anonymous class contributes null. + * + * @var list + */ + private array $activeClassLikeNames = []; + + /** @var list */ + private array $activeFunctionNames = []; + + /** @var list */ + private array $activeFunctionLikeAnalyses = []; + + /** + * For each class-like currently being entered, how many function-likes + * were active at that point. `$this` inside a class-like body binds to + * that class-like, so only closures entered after it (deeper in the + * stack) are the ones reading it. + * + * @var list + */ + private array $functionLikeDepthAtClassLikeEntry = []; + + /** + * Every function-like entered in the current file, in source order. Their + * nodes are built in afterTraverse(), once every function declared in the + * file is known and unqualified function calls can be resolved. + * + * @var list + */ + private array $fileFunctionLikeAnalyses = []; + + public function __construct( + private readonly LayerResolverInterface $layerResolver + ) { + $this->constExprEvaluator = new ConstExprEvaluator(function (Expr $expr): string { + if ( + $expr instanceof ClassConstFetch + && $expr->name instanceof Identifier + && $expr->name->toLowerString() === 'class' + && $expr->class instanceof Name + ) { + $className = $this->resolveClassLikeName($expr->class); + + if ($className !== null) { + return $className; + } + } + + throw new ConstExprEvaluationException('Expression is not a resolvable class name.'); + }); + } + + /** @param array $tokens The file's token stream, for the facts its AST does not carry */ + public function setCurrentFile(string $file, array $tokens = []): void + { + $this->currentFile = $file; + $this->currentTokens = $tokens; + $this->currentFileReferences = []; + $this->currentFileInstantiations = []; + $this->nonCanonicalKeywordConstants = []; + $this->numericLiterals = []; + $this->currentNamespaceUses = []; + $this->fileClassLikes = []; + $this->anonymousClassEnclosingNames = []; + $this->fileFunctions = []; + $this->classLikeAnalysis = []; + $this->activeClassLikeAnalyses = []; + $this->activeMethodComplexities = []; + $this->activeClassLikeNames = []; + $this->activeFunctionNames = []; + $this->activeFunctionLikeAnalyses = []; + $this->fileFunctionLikeAnalyses = []; + $this->functionLikeDepthAtClassLikeEntry = []; + } + + /** @return list */ + public function getClassNodes(): array + { + return $this->classNodes; + } + + /** @return list */ + public function getFunctionNodes(): array + { + return $this->functionNodes; + } + + /** @return list */ + public function getAnonymousFunctionNodes(): array + { + return $this->anonymousFunctionNodes; + } + + /** @return list */ + public function getAnonymousClassNodes(): array + { + return $this->anonymousClassNodes; + } + + /** + * References to class-likes made outside any class-like scope, per file — + * procedural functions and top-level statements. + * + * @return array> + */ + public function getFileReferences(): array + { + return $this->fileReferences; + } + + /** + * Keyword constants of the file traversed last that are not spelled in + * lowercase, see MustUseLowercaseKeywordConstantRule. + * + * @return list + */ + public function getNonCanonicalKeywordConstants(): array + { + return $this->nonCanonicalKeywordConstants; + } + + /** + * Numeric literals of the file traversed last, as [line, spelling as + * written, evaluated value]. + * + * @return list + */ + public function getNumericLiterals(): array + { + return $this->numericLiterals; + } + + /** + * Class-like instantiations (`new X`, with self/static/parent resolved to + * the class names they target), per file. `new` on an abstract class is + * fatal, so these are what an extended class needs to stay concrete. + * + * `new parent()` inside a trait is recorded as a marker instead, see + * {@see traitFromParentMarker()}. + * + * @return array> + */ + public function getFileInstantiations(): array + { + return $this->fileInstantiations; + } + + /** + * Marker recorded in place of a class name for `new ()` whose + * target depends on classes not yet collected: `self`, `static`, and + * `parent` inside a trait resolve against each class using the trait, + * and `static` inside a class also covers its descendants. + * + * @param 'self'|'static'|'parent' $keyword + */ + public static function deferredInstantiationMarker(string $keyword, string $classLikeName): string + { + return $keyword . self::DEFERRED_MARKER_SEPARATOR . $classLikeName; + } + + /** + * The keyword and class-like name carried by a deferred instantiation + * marker, or null when the instantiation is a plain class name. + * + * @return array{0: 'self'|'static'|'parent', 1: string}|null + */ + public static function parseDeferredInstantiationMarker(string $instantiation): ?array + { + $separatorPosition = strpos($instantiation, self::DEFERRED_MARKER_SEPARATOR); + + if ($separatorPosition === false) { + return null; + } + + $keyword = substr($instantiation, 0, $separatorPosition); + + if (! in_array($keyword, ['self', 'static', 'parent'], true)) { + return null; + } + + return [$keyword, substr($instantiation, $separatorPosition + 1)]; + } + + public function enterNode(Node $node): null + { + if (! isset(self::ENTER_NODES[$node::class])) { + return null; + } + + // This ordering is about the instanceof tests in this method, not the + // traversal: the traverser still enters a statement before the + // expressions inside it. Variable is the most frequent node class, so + // testing it first spares every variable the Stmt and FunctionLike + // checks and the collectNodeAnalysis() call. Only `$this` and + // superglobals are recorded; outside a class-like or function-like + // scope both handlers record nothing, so no scope check is needed. + if ($node instanceof Variable) { + if ($node->name === 'this') { + $this->markThisUsage(); + } elseif (is_string($node->name) && isset(self::SUPERGLOBALS[$node->name])) { + $this->addSuperglobal('$' . $node->name); + } + + return null; + } + + // The scope-tracking node types are all statements, so the far more + // frequent expression/name/identifier nodes skip their checks with a + // single instanceof. + if ($node instanceof Stmt) { + if ($node instanceof Namespace_) { + $this->currentNamespaceUses = []; + + return null; + } + + if ($node instanceof Use_) { + foreach ($node->uses as $use) { + $this->currentNamespaceUses[$use->name->toString()] = true; + } + + return null; + } + + if ($node instanceof GroupUse) { + $prefix = $node->prefix->toString(); + + foreach ($node->uses as $use) { + $this->currentNamespaceUses[$prefix . '\\' . $use->name->toString()] = true; + } + + return null; + } + + if ($node instanceof Function_) { + $functionName = $this->resolveFunctionDeclarationName($node); + + $this->fileFunctions[$functionName] = true; + $this->activeFunctionNames[] = $functionName; + $this->startFunctionLikeAnalysis($node); + + return null; + } + + if ($node instanceof ClassLike) { + $classLikeName = $node->name instanceof Identifier + ? $this->resolveClassName($node) + : null; + + $this->activeClassLikeScopes[] = $this->createClassLikeScope($node, $classLikeName); + $this->activeClassLikeNames[] = $classLikeName; + $this->functionLikeDepthAtClassLikeEntry[] = count($this->activeFunctionLikeAnalyses); + $this->startClassLikeAnalysis($node); + + return null; + } + + if ($node instanceof ClassMethod) { + $this->startMethodAnalysis(); + + return null; + } + + if (isset(self::MEMBER_NODES[$node::class])) { + $this->collectMember($node); + + return null; + } + } elseif ($node instanceof FunctionLike) { + $this->startFunctionLikeAnalysis($node); + + return null; + } + + $this->collectNodeAnalysis($node); + + return null; + } + + public function leaveNode(Node $node): null + { + if (! isset(self::LEAVE_NODES[$node::class])) { + return null; + } + + // Both instantiation handlers run on leave, once the NameResolver + // has resolved the nested name nodes (e.g. Base::class inside the + // class expression). They only match expressions, and ClassMethod / + // ClassLike are statements, so one instanceof splits the two groups. + if ($node instanceof Expr) { + if ($node instanceof Closure || $node instanceof ArrowFunction) { + $this->finishFunctionLikeAnalysis(); + + return null; + } + + // Instantiations are tracked separately from plain references: + // `new` on an abstract class is fatal, so instantiation is the one + // usage that requires an extended class to stay concrete — type + // hints, instanceof checks, and ::class constants all keep working + // once a class becomes abstract. + if ($node instanceof New_) { + $this->collectInstantiation($node); + + return null; + } + + // A ReflectionClass construction call instantiates the reflected + // class when the reflection target is statically resolvable. The + // `new` receiver is checked first: it is the rare shape, so the + // common method call skips the name lowering entirely. + if ( + ($node instanceof MethodCall || $node instanceof NullsafeMethodCall) + && $node->var instanceof New_ + && $node->name instanceof Identifier + && isset(self::REFLECTION_CONSTRUCTION_METHODS[$node->name->toLowerString()]) + ) { + $this->collectReflectionInstantiation($node->var); + } + + return null; + } + + if ($node instanceof ClassMethod) { + $this->finishMethodAnalysis($node); + + return null; + } + + if ($node instanceof EnumCase) { + $this->collectEnumCase($node); + + return null; + } + + if ($node instanceof Function_) { + $this->finishFunctionLikeAnalysis(); + array_pop($this->activeFunctionNames); + + return null; + } + + // Every remaining LEAVE_NODES entry is a class-like statement. + assert($node instanceof ClassLike); + + array_pop($this->activeClassLikeScopes); + array_pop($this->activeClassLikeNames); + array_pop($this->functionLikeDepthAtClassLikeEntry); + + // Anonymous classes never become ClassNodes, but the class they + // extend, the interfaces they implement, and the traits they use + // are still used within the scanned paths, and their members and + // body facts are collected like a named class's. + if ($node instanceof Class_ && $node->isAnonymous()) { + // Its own (nameless) entry is already popped, so the innermost + // active names are the named scopes declaring it; they also + // resolve its layer, as they do for an anonymous function. + $this->anonymousClassEnclosingNames[spl_object_id($node)] = [ + $this->innermostActiveClassLikeName(), + $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames), + ]; + } + + $this->fileClassLikes[] = $node; + array_pop($this->activeClassLikeAnalyses); + + return null; + } + + /** @param Node[] $nodes */ + public function afterTraverse(array $nodes): null + { + foreach ($this->fileClassLikes as $fileClassLike) { + if ($fileClassLike instanceof Class_ && $fileClassLike->isAnonymous()) { + $this->collectAnonymousClass($fileClassLike); + } else { + $this->collectClassLike($fileClassLike); + } + } + + foreach ($this->fileFunctionLikeAnalyses as $fileFunctionLikeAnalysis) { + $this->collectFunctionLike($fileFunctionLikeAnalysis); + } + + if ($this->currentFileReferences !== []) { + $this->fileReferences[$this->currentFile] = array_keys($this->currentFileReferences); + $this->currentFileReferences = []; + } + + if ($this->currentFileInstantiations !== []) { + $this->fileInstantiations[$this->currentFile] = array_keys($this->currentFileInstantiations); + $this->currentFileInstantiations = []; + } + + $this->fileClassLikes = []; + $this->anonymousClassEnclosingNames = []; + $this->classLikeAnalysis = []; + $this->activeClassLikeAnalyses = []; + $this->activeClassLikeScopes = []; + $this->activeMethodComplexities = []; + $this->activeClassLikeNames = []; + $this->activeFunctionNames = []; + $this->activeFunctionLikeAnalyses = []; + $this->fileFunctionLikeAnalyses = []; + $this->functionLikeDepthAtClassLikeEntry = []; + + return null; + } + + /** + * A named class-like seeds its dependencies with the namespace imports. + * An anonymous class does not: like a function-like's, its file's imports + * belong to the file (and the named class-like declaring it), not to it. + */ + private function startClassLikeAnalysis(ClassLike $classLike): void + { + $classLikeId = spl_object_id($classLike); + $classLikeAnalysis = new ClassLikeAnalysis($classLike instanceof Interface_); + + if ($classLike->name instanceof Identifier) { + $classLikeAnalysis->dependencies = $this->currentNamespaceUses; + } + + $this->classLikeAnalysis[$classLikeId] = $classLikeAnalysis; + $this->activeClassLikeAnalyses[] = $classLikeAnalysis; + } + + /** + * The analysis of the class-like declaring the member being entered: the + * innermost active class-like, named or anonymous. + */ + private function declaringClassLikeAnalysis(): ?ClassLikeAnalysis + { + $analysis = end($this->activeClassLikeAnalyses); + + return $analysis instanceof ClassLikeAnalysis ? $analysis : null; + } + + private function startMethodAnalysis(): void + { + if ($this->declaringClassLikeAnalysis() instanceof ClassLikeAnalysis) { + $this->activeMethodComplexities[] = 1; + } + } + + /** + * Members other than methods carry no body facts, so they are recorded + * completely the moment the traverser enters them. + */ + private function collectMember(Stmt $stmt): void + { + $analysis = $this->declaringClassLikeAnalysis(); + + if (! $analysis instanceof ClassLikeAnalysis) { + return; + } + + if ($stmt instanceof Property) { + $visibility = $this->resolveVisibilityName($stmt); + $hasExplicitVisibility = VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags); + + foreach ($stmt->props as $prop) { + $analysis->properties[] = new PropertyNode( + name: (string) $prop->name, + visibility: $visibility, + hasExplicitVisibility: $hasExplicitVisibility, + line: $prop->getStartLine(), + ); + } + + return; + } + + if ($stmt instanceof ClassConst) { + $visibility = $this->resolveVisibilityName($stmt); + $hasExplicitVisibility = VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags); + + foreach ($stmt->consts as $const) { + $analysis->constants[] = new ConstantNode( + name: (string) $const->name, + visibility: $visibility, + hasExplicitVisibility: $hasExplicitVisibility, + line: $const->getStartLine(), + ); + } + + return; + } + + if ($stmt instanceof TraitUse && ! $analysis->isInterface) { + foreach ($stmt->traits as $trait) { + $analysis->traits[] = $trait->toString(); + } + } + } + + private function collectEnumCase(EnumCase $enumCase): void + { + $analysis = $this->declaringClassLikeAnalysis(); + + if (! $analysis instanceof ClassLikeAnalysis) { + return; + } + + $analysis->enumCases[] = new EnumCaseNode( + name: (string) $enumCase->name, + line: $enumCase->getStartLine(), + value: $this->resolveEnumCaseValue($enumCase->expr), + ); + } + + /** + * Unlike a class-like, a function-like does not seed its dependencies + * with the namespace imports: a file may declare hundreds of functions, + * and its imports belong to the file, not to each of them. Only what the + * signature and body reference is recorded. + */ + private function startFunctionLikeAnalysis(FunctionLike $functionLike): void + { + $functionLikeAnalysis = new FunctionLikeAnalysis( + $functionLike, + $this->innermostActiveClassLikeName(), + $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames), + ); + + $this->activeFunctionLikeAnalyses[] = $functionLikeAnalysis; + $this->fileFunctionLikeAnalyses[] = $functionLikeAnalysis; + } + + private function innermostActiveClassLikeName(): ?string + { + for ($index = count($this->activeClassLikeNames) - 1; $index >= 0; $index--) { + if ($this->activeClassLikeNames[$index] !== null) { + return $this->activeClassLikeNames[$index]; + } + } + + return null; + } + + /** + * The method's complexity is final once its body has been left, so the + * MethodNode is built here; a constructor's promoted parameters are the + * class's properties. + */ + private function finishMethodAnalysis(ClassMethod $classMethod): void + { + $analysis = $this->declaringClassLikeAnalysis(); + + if (! $analysis instanceof ClassLikeAnalysis) { + return; + } + + $cyclomaticComplexity = array_pop($this->activeMethodComplexities); + + $analysis->methods[] = new MethodNode( + name: (string) $classMethod->name, + visibility: $this->resolveVisibilityName($classMethod), + hasReturnType: $classMethod->returnType instanceof Node, + isStatic: $classMethod->isStatic(), + paramCount: count($classMethod->params), + cyclomaticComplexity: $cyclomaticComplexity ?? 1, + lineCount: $this->calculateLineCount($classMethod), + hasExplicitVisibility: VisibilityFlagChecker::hasExplicitVisibilityFlag($classMethod->flags), + line: $classMethod->getStartLine(), + isMagic: $classMethod->isMagic(), + ); + + if ($classMethod->name->toLowerString() !== '__construct') { + return; + } + + foreach ($classMethod->params as $param) { + if ( + ! $param->isPromoted() + || ! $param->var instanceof Variable + || ! is_string($param->var->name) + ) { + continue; + } + + $analysis->properties[] = new PropertyNode( + name: (string) $param->var->name, + visibility: $this->resolveVisibilityName($param), + hasExplicitVisibility: VisibilityFlagChecker::hasExplicitVisibilityFlag($param->flags), + line: $param->getStartLine(), + ); + } + } + + /** + * Roll a completed function-like's body facts into its lexical parent. + * Class-like facts are still collected directly during traversal, so a + * top-level function-like has no additional merge target here. + */ + private function finishFunctionLikeAnalysis(): void + { + $activeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeCount === 0) { + return; + } + + $functionLikeAnalysis = array_pop($this->activeFunctionLikeAnalyses); + + if ($activeCount === 1) { + return; + } + + $parent = $this->activeFunctionLikeAnalyses[$activeCount - 2]; + + array_push($parent->functionCallNames, ...$functionLikeAnalysis->functionCallNames); + + $parent->dependencies += $functionLikeAnalysis->dependencies; + $parent->superglobals += $functionLikeAnalysis->superglobals; + $parent->languageConstructs += $functionLikeAnalysis->languageConstructs; + + if ($functionLikeAnalysis->cyclomaticComplexity > 1) { + $parent->cyclomaticComplexity += $functionLikeAnalysis->cyclomaticComplexity - 1; + } + } + + private function collectNodeAnalysis(Node $node): void + { + if ($node instanceof Int_ || $node instanceof Float_) { + $rawValue = $node->getAttribute('rawValue'); + + // Parser-created scalar nodes always carry rawValue. Programmatic + // nodes may not, and non-finite floats cannot be cache-serialized. + if ( + is_string($rawValue) + && ($node instanceof Int_ || is_finite($node->value)) + ) { + $this->numericLiterals[] = [$node->getStartLine(), $rawValue, $node->value]; + } + + return; + } + + // A class-name-shaped string literal may feed `new $class`, + // `$obj instanceof $class`, class_exists(), container ids, and so on. + // Whether it appears inside a class-like or in procedural code, treat + // it as a file-level reference so the named class-like stays alive. + if ($node instanceof String_) { + // A leading `\` is a valid fully-qualified spelling + // (`'\App\Contract'`); strip it so the stored name matches the + // ClassNode::$className form used for usage lookups. + $value = $this->stripLeadingNamespaceSeparator($node->value); + + if ( + preg_match(self::CLASS_LIKE_STRING_PATTERN, $value) === 1 + && ! isset(self::KEYWORD_CONSTANTS[strtolower($value)]) + ) { + $this->currentFileReferences[$value] = true; + } + + return; + } + + if ($node instanceof FullyQualified) { + $name = $node->toString(); + + if (isset(self::KEYWORD_CONSTANTS[strtolower($name)])) { + return; + } + + if ($this->activeClassLikeAnalyses === []) { + // Outside any class-like scope — procedural functions and + // top-level statements — a class-like reference still keeps + // the referenced class-like alive. + $this->currentFileReferences[$name] = true; + } + + $this->addDependency($name); + + return; + } + + if ($node instanceof ConstFetch) { + $this->collectKeywordConstant($node->name); + + return; + } + + if ($this->activeClassLikeAnalyses === [] && $this->activeFunctionLikeAnalyses === []) { + return; + } + + // Branch nodes (conditions, loops, boolean operators) are among the + // most frequent remaining node types, so they dispatch on one hash + // lookup before the rarer per-type checks below. + $nodeClass = $node::class; + + if (isset(self::COMPLEXITY_BRANCH_NODES[$nodeClass])) { + foreach ($this->activeMethodComplexities as &$activeMethodComplexity) { + $activeMethodComplexity++; + } + + unset($activeMethodComplexity); + + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeFunctionLikeCount > 0) { + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->cyclomaticComplexity++; + } + + return; + } + + if ($node instanceof FuncCall) { + if ($node->name instanceof Name) { + $functionName = $node->name->toLowerString(); + + // PHP 8.4 generalized exit/die (e.g. named arguments) parse as + // FuncCall instead of Exit_, but remain language constructs + if ($functionName === 'exit' || $functionName === 'die') { + $this->addLanguageConstruct($functionName); + } else { + $this->addFunctionCallName($node->name); + } + } + + return; + } + + if ($node instanceof Exit_) { + $this->addLanguageConstruct( + $node->getAttribute('kind') === Exit_::KIND_DIE + ? 'die' + : 'exit' + ); + + return; + } + + if ($node instanceof Include_) { + $this->addLanguageConstruct(match ($node->type) { + Include_::TYPE_REQUIRE => 'require', + Include_::TYPE_INCLUDE_ONCE => 'include_once', + Include_::TYPE_REQUIRE_ONCE => 'require_once', + default => 'include', + }); + + return; + } + + $languageConstruct = self::LANGUAGE_CONSTRUCT_NODES[$nodeClass] ?? null; + + if ($languageConstruct !== null) { + $this->addLanguageConstruct($languageConstruct); + } + } + + /** + * Records a `true`, `false`, or `null` fetch whose spelling is not the + * canonical lowercase. The NameResolver has already replaced an unqualified + * name outside a namespace with a FullyQualified clone, so whether the `\` + * was written is read from the source span instead: it is one character + * longer than the name. Other spellings, such as `namespace\TRUE`, are + * out of scope. + */ + private function collectKeywordConstant(Name $name): void + { + $spelling = $name->toString(); + + if (isset(self::KEYWORD_CONSTANTS[$spelling])) { + return; + } + + $length = strlen($spelling); + + if ($length !== 4 && $length !== 5) { + return; + } + + $keyword = strtolower($spelling); + + if (! isset(self::KEYWORD_CONSTANTS[$keyword])) { + return; + } + + $spanLength = $name->getEndFilePos() - $name->getStartFilePos() + 1; + + if ($spanLength === $length + 1) { + $spelling = '\\' . $spelling; + } elseif ($spanLength !== $length) { + return; + } + + $this->nonCanonicalKeywordConstants[] = [$name->getStartLine(), $spelling]; + } + + private function collectInstantiation(New_ $new): void + { + $class = $new->class; + + if ($class instanceof Name) { + $className = $this->resolveClassLikeName($class); + + if ($className !== null) { + $this->currentFileInstantiations[$className] = true; + } + + return; + } + + // Anonymous classes (`new class {}`) are tracked as + // AnonymousClassNodes; constant class expressions may still resolve + // below. Runtime-fed dynamic instantiations (`new \$class` from a + // parameter, unserialize(), containers) are part of the documented + // scanned-code boundary and resolve to nothing. + if (! $class instanceof Expr) { + return; + } + + // `new (X::class)` / `new ('App\X')` constant class expressions. + $className = $this->resolveClassNameExpr($class); + + if ($className !== null) { + $this->currentFileInstantiations[$className] = true; + } + } + + /** + * Resolve a class-like name node to a fully qualified name (or deferred + * marker): either it is already fully qualified, or it is a + * self/static/parent keyword resolved against the enclosing class-like + * scope. Returns null when there is no scope to resolve against. + */ + private function resolveClassLikeName(Name $name): ?string + { + if ($name instanceof FullyQualified) { + return $name->toString(); + } + + // After name resolution only self, static, and parent survive as + // plain names. + $scope = end($this->activeClassLikeScopes); + + if ($scope === false) { + return null; + } + + return $scope[$name->toLowerString()] ?? null; + } + + /** + * A trait is never instantiated itself: `self`, `static`, and `parent` + * target whichever class uses the trait, and `static` in a class also + * targets its descendants. Both are only known once every class has been + * collected, so those scope entries carry a marker the analyser resolves + * later, in place of a class name. + * + * @return array{self: string|null, static: string|null, parent: string|null} + */ + private function createClassLikeScope(ClassLike $classLike, ?string $classLikeName): array + { + $parent = $classLike instanceof Class_ && $classLike->extends instanceof Name + ? $classLike->extends->toString() + : null; + + if ($classLikeName === null) { + return ['self' => null, 'static' => null, 'parent' => $parent]; + } + + if ($classLike instanceof Trait_) { + return [ + 'self' => self::deferredInstantiationMarker('self', $classLikeName), + 'static' => self::deferredInstantiationMarker('static', $classLikeName), + 'parent' => self::deferredInstantiationMarker('parent', $classLikeName), + ]; + } + + return [ + 'self' => $classLikeName, + 'static' => self::deferredInstantiationMarker('static', $classLikeName), + 'parent' => $parent, + ]; + } + + /** + * Resolve `new ReflectionClass()` to the + * reflected class name, or null for anything else. + */ + private function resolveReflectionTarget(New_ $new): ?string + { + if (! $new->class instanceof Name) { + return null; + } + + if (strcasecmp($new->class->toString(), 'ReflectionClass') !== 0) { + return null; + } + + $firstArg = $new->args[0] ?? null; + + if (! $firstArg instanceof Arg) { + return null; + } + + return $this->resolveClassNameExpr($firstArg->value); + } + + /** + * Record the reflected class as instantiated when a construction method is + * called chained on a `new` receiver that is a resolvable ReflectionClass. + * Anything else — variable-held reflections, runtime-named targets — + * records nothing: that is part of the documented scanned-code boundary. + */ + private function collectReflectionInstantiation(New_ $new): void + { + $reflectionTarget = $this->resolveReflectionTarget($new); + + if ($reflectionTarget !== null) { + $this->currentFileInstantiations[$reflectionTarget] = true; + } + } + + /** + * Evaluate a constant expression to a class-name string: 'App\X' literals, + * X::class (including self/static/parent::class, which may yield a + * deferred marker), and concatenations of those. Anything depending on + * runtime values resolves to null. + */ + private function resolveClassNameExpr(Expr $expr): ?string + { + if (! $expr instanceof String_ && ! $expr instanceof ClassConstFetch && ! $expr instanceof Concat) { + return null; + } + + try { + /** @var string $value */ + $value = $this->constExprEvaluator->evaluateSilently($expr); + } catch (ConstExprEvaluationException) { + return null; + } + + $marker = self::parseDeferredInstantiationMarker($value); + + if ($marker === null) { + $value = $this->stripLeadingNamespaceSeparator($value); + } + + return preg_match(self::CLASS_LIKE_STRING_PATTERN, $marker[1] ?? $value) === 1 + ? $value + : null; + } + + /** + * Statically resolve a backed enum case value. Returns null for a pure + * enum case and for values that depend on symbols outside the expression + * (global constants, other class constants), which the analyser cannot + * evaluate. + */ + private function resolveEnumCaseValue(?Expr $expr): int|string|null + { + if (! $expr instanceof Expr) { + return null; + } + + try { + $value = $this->constExprEvaluator->evaluateSilently($expr); + } catch (ConstExprEvaluationException) { + return null; + } + + return is_int($value) || is_string($value) ? $value : null; + } + + /** + * `'\App\X'` and `'App\X'` name the same class; the collector stores the + * latter form so usage keys line up with ClassNode::$className. + */ + private function stripLeadingNamespaceSeparator(string $name): string + { + return str_starts_with($name, '\\') ? substr($name, 1) : $name; + } + + private function addDependency(string $dependency): void + { + foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { + $activeClassLikeAnalysis->dependencies[$dependency] = true; + } + + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeFunctionLikeCount > 0) { + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->dependencies[$dependency] = true; + } + } + + private function addFunctionCallName(Name $functionCallName): void + { + foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { + $activeClassLikeAnalysis->functionCallNames[] = $functionCallName; + } + + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeFunctionLikeCount > 0) { + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->functionCallNames[] = $functionCallName; + } + } + + private function addSuperglobal(string $superglobal): void + { + foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { + $activeClassLikeAnalysis->superglobals[$superglobal] = true; + } + + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeFunctionLikeCount > 0) { + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1]->superglobals[$superglobal] = true; + } + } + + /** + * `$this` belongs to every closure entered since the innermost class-like, + * as a non-static closure captures it from its enclosing scope through + * any number of nested non-static closures. + */ + private function markThisUsage(): void + { + $depth = $this->functionLikeDepthAtClassLikeEntry === [] + ? 0 + : end($this->functionLikeDepthAtClassLikeEntry); + + for ($index = count($this->activeFunctionLikeAnalyses) - 1; $index >= $depth; $index--) { + $this->activeFunctionLikeAnalyses[$index]->usesThis = true; + } + } + + private function addLanguageConstruct(string $languageConstruct): void + { + foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { + $activeClassLikeAnalysis->languageConstructs[$languageConstruct] = true; + } + + $activeFunctionLikeCount = count($this->activeFunctionLikeAnalyses); + + if ($activeFunctionLikeCount > 0) { + $this->activeFunctionLikeAnalyses[$activeFunctionLikeCount - 1] + ->languageConstructs[$languageConstruct] = true; + } + } + + private function collectClassLike(ClassLike $classLike): void + { + $classLikeId = spl_object_id($classLike); + $analysis = $this->collectClassLikeAnalysis($classLikeId); + $className = $this->resolveClassName($classLike); + [$layer, $layers] = $this->resolveLayerData($className); + $implements = $this->collectImplements($classLike); + $interfaceExtends = $this->collectInterfaceExtends($classLike); + + $this->classNodes[] = new ClassNode( + className: $className, + file: $this->currentFile, + line: $classLike->getStartLine(), + layer: $layer, + extends: $classLike instanceof Class_ && $classLike->extends instanceof Name + ? $classLike->extends->toString() + : null, + isAbstract: $classLike instanceof Class_ && $classLike->isAbstract(), + isFinal: $classLike instanceof Class_ && $classLike->isFinal(), + isInterface: $classLike instanceof Interface_, + isReadonly: $classLike instanceof Class_ && $classLike->isReadonly(), + isTrait: $classLike instanceof Trait_, + dependencies: $analysis['dependencies'], + implements: $implements, + traits: $analysis['traits'], + methods: $analysis['methods'], + constants: $analysis['constants'], + properties: $analysis['properties'], + functionCalls: $analysis['functionCalls'], + superglobals: $analysis['superglobals'], + languageConstructs: $analysis['languageConstructs'], + layers: $layers, + isEnum: $classLike instanceof Enum_, + interfaceExtends: $interfaceExtends, + enumCases: $analysis['enumCases'], + enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instanceof Identifier + ? $classLike->scalarType->toLowerString() + : null, + ); + } + + private function collectAnonymousClass(Class_ $class): void + { + $classLikeId = spl_object_id($class); + $analysis = $this->collectClassLikeAnalysis($classLikeId); + [$enclosingClassName, $enclosingFunctionName] = $this->anonymousClassEnclosingNames[$classLikeId]; + [$layer, $layers] = $this->resolveLayerData( + $enclosingClassName ?? $enclosingFunctionName ?? '' + ); + + $this->anonymousClassNodes[] = new AnonymousClassNode( + file: $this->currentFile, + line: $class->getStartLine(), + extends: $class->extends instanceof Name ? $class->extends->toString() : null, + implements: $this->collectImplements($class), + traits: $analysis['traits'], + layer: $layer, + enclosingClassName: $enclosingClassName, + enclosingFunctionName: $enclosingFunctionName, + hasEmptyParentheses: AnonymousClassParentheses::emptyTokenRange($this->currentTokens, $class) !== null, + layers: $layers, + isReadonly: $class->isReadonly(), + dependencies: $analysis['dependencies'], + methods: $analysis['methods'], + constants: $analysis['constants'], + properties: $analysis['properties'], + functionCalls: $analysis['functionCalls'], + superglobals: $analysis['superglobals'], + languageConstructs: $analysis['languageConstructs'], + ); + } + + private function collectFunctionLike(FunctionLikeAnalysis $functionLikeAnalysis): void + { + $functionLike = $functionLikeAnalysis->functionLike; + $functionCalls = []; + + foreach ($functionLikeAnalysis->functionCallNames as $functionCallName) { + $functionCalls[] = $this->resolveFunctionName($functionCallName); + } + + $dependencies = array_keys($functionLikeAnalysis->dependencies); + $functionCalls = array_values(array_unique($functionCalls)); + $superglobals = array_keys($functionLikeAnalysis->superglobals); + $languageConstructs = array_keys($functionLikeAnalysis->languageConstructs); + $hasReturnType = $functionLike->getReturnType() instanceof Node; + $paramCount = count($functionLike->getParams()); + $lineCount = $this->calculateLineCount($functionLike); + + if ($functionLike instanceof Function_) { + $functionName = $this->resolveFunctionDeclarationName($functionLike); + [$layer, $layers] = $this->resolveLayerData($functionName); + + $this->functionNodes[] = new FunctionNode( + functionName: $functionName, + file: $this->currentFile, + line: $functionLike->getStartLine(), + layer: $layer, + hasReturnType: $hasReturnType, + paramCount: $paramCount, + cyclomaticComplexity: $functionLikeAnalysis->cyclomaticComplexity, + lineCount: $lineCount, + dependencies: $dependencies, + functionCalls: $functionCalls, + superglobals: $superglobals, + languageConstructs: $languageConstructs, + layers: $layers, + ); + + return; + } + + // The layer of an anonymous function is resolved by its file and, for + // class-name pattern layers, by the named scope declaring it. + $scopeName = $functionLikeAnalysis->enclosingClassName + ?? $functionLikeAnalysis->enclosingFunctionName + ?? ''; + [$layer, $layers] = $this->resolveLayerData($scopeName); + + $this->anonymousFunctionNodes[] = new AnonymousFunctionNode( + file: $this->currentFile, + line: $functionLike->getStartLine(), + layer: $layer, + isArrowFunction: $functionLike instanceof ArrowFunction, + isStatic: ($functionLike instanceof Closure || $functionLike instanceof ArrowFunction) + && $functionLike->static, + enclosingClassName: $functionLikeAnalysis->enclosingClassName, + enclosingFunctionName: $functionLikeAnalysis->enclosingFunctionName, + usesThis: $functionLikeAnalysis->usesThis, + hasReturnType: $hasReturnType, + paramCount: $paramCount, + cyclomaticComplexity: $functionLikeAnalysis->cyclomaticComplexity, + lineCount: $lineCount, + dependencies: $dependencies, + functionCalls: $functionCalls, + superglobals: $superglobals, + languageConstructs: $languageConstructs, + layers: $layers, + ); + } + + /** + * Resolve both layer representations for a scope. With zero or one match, + * resolveAll() already determines the primary layer; the separate + * resolve() pass is only needed for overlapping layer matches. Repeated + * lookups are cached by ChainLayerResolver. + * + * @return array{0: string|null, 1: list} + */ + private function resolveLayerData(string $scopeName): array + { + $layers = $this->layerResolver->resolveAll($scopeName, $this->currentFile); + $layer = match (count($layers)) { + 0 => null, + 1 => $layers[0], + default => $this->layerResolver->resolve($scopeName, $this->currentFile), + }; + + return [$layer, $layers]; + } + + private function resolveFunctionDeclarationName(Function_ $function): string + { + return isset($function->namespacedName) + ? $function->namespacedName->toString() + : (string) $function->name; + } + + private function resolveClassName(ClassLike $classLike): string + { + return isset($classLike->namespacedName) + ? $classLike->namespacedName->toString() + : (string) $classLike->name; + } + + /** + * @return array{ + * dependencies: list, + * functionCalls: string[], + * superglobals: string[], + * languageConstructs: string[], + * traits: string[], + * constants: ConstantNode[], + * properties: PropertyNode[], + * methods: MethodNode[], + * enumCases: EnumCaseNode[] + * } + */ + private function collectClassLikeAnalysis(int $classLikeId): array + { + $analysis = $this->classLikeAnalysis[$classLikeId] ?? new ClassLikeAnalysis(false); + $functionCalls = []; + + foreach ($analysis->functionCallNames as $functionCallName) { + $functionCalls[] = $this->resolveFunctionName($functionCallName); + } + + return [ + 'dependencies' => array_keys($analysis->dependencies), + 'functionCalls' => array_values(array_unique($functionCalls)), + 'superglobals' => array_keys($analysis->superglobals), + 'languageConstructs' => array_keys($analysis->languageConstructs), + 'traits' => $analysis->traits, + 'constants' => $analysis->constants, + 'properties' => $analysis->properties, + 'methods' => $analysis->methods, + 'enumCases' => $analysis->enumCases, + ]; + } + + private function resolveFunctionName(Name $name): string + { + $functionName = $name->toString(); + + if ($name instanceof FullyQualified) { + return $functionName; + } + + $namespacedName = $name->getAttribute('namespacedName'); + + if ($namespacedName instanceof Name) { + $namespacedNameString = $namespacedName->toString(); + + if (isset($this->fileFunctions[$namespacedNameString])) { + return $namespacedNameString; + } + } + + return $functionName; + } + + /** + * @return string[] + */ + private function collectImplements(ClassLike $classLike): array + { + $interfaces = []; + + if ($classLike instanceof Class_ || $classLike instanceof Enum_) { + foreach ($classLike->implements as $interface) { + $interfaces[] = $interface->toString(); + } + } + + return $interfaces; + } + + /** + * @return string[] + */ + private function collectInterfaceExtends(ClassLike $classLike): array + { + if (! $classLike instanceof Interface_) { + return []; + } + + $parents = []; + + foreach ($classLike->extends as $parent) { + $parents[] = $parent->toString(); + } + + return $parents; + } + + private function resolveVisibilityName(ClassMethod|ClassConst|Property|Param $node): string + { + if ($node->isProtected()) { + return 'protected'; + } + + if ($node->isPrivate()) { + return 'private'; + } + + return 'public'; + } + + /** + * Lines spanned by the body statements. An arrow function's body is its + * single expression, which php-parser exposes as one return statement. + */ + private function calculateLineCount(FunctionLike $functionLike): int + { + $stmts = $functionLike->getStmts(); + + if ($stmts === null || $stmts === []) { + return 0; + } + + $lastIndex = count($stmts) - 1; + return $stmts[$lastIndex]->getEndLine() - $stmts[0]->getStartLine() + 1; + } +} diff --git a/src/Analyser/AnalysisNodeExtractor.php b/src/Analyser/AnalysisNodeExtractor.php new file mode 100644 index 00000000..b0974f77 --- /dev/null +++ b/src/Analyser/AnalysisNodeExtractor.php @@ -0,0 +1,97 @@ +fileAnalysisProvider = $fileAnalysisProvider ?? new FileAnalysisProvider(); + } + + /** @param list $files */ + public function extract( + array $files, + ?ProgressHandlerInterface $progressHandler = null, + bool $withFileAnalysis = true, + ): ExtractionResult { + $analysisNodeCollector = new AnalysisNodeCollector($this->layerResolver); + $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); + $fileAnalyses = []; + + foreach ($files as $file) { + try { + $ast = $this->fileAnalysisProvider->ast($file, $withFileAnalysis); + $nonCanonicalKeywordConstants = []; + $numericLiterals = []; + + if ($ast !== null && $ast !== []) { + $analysisNodeCollector->setCurrentFile($file, $this->fileAnalysisProvider->tokens()); + $nodeTraverser->traverse($ast); + + $nonCanonicalKeywordConstants = $analysisNodeCollector->getNonCanonicalKeywordConstants(); + $numericLiterals = $analysisNodeCollector->getNumericLiterals(); + } + + // Analysed after the traversal so the facts only the collector + // records reach the file analysis without a second AST walk. + if ($withFileAnalysis) { + $fileAnalyses[$file] = $this->fileAnalysisProvider->analyse( + $file, + $nonCanonicalKeywordConstants, + $numericLiterals, + ); + } + } finally { + if ($withFileAnalysis) { + $this->fileAnalysisProvider->releaseAst($file); + } + + $progressHandler?->advance($file); + } + } + + $extractionResult = new ExtractionResult( + $analysisNodeCollector->getClassNodes(), + $fileAnalyses, + $analysisNodeCollector->getAnonymousClassNodes(), + $analysisNodeCollector->getFileReferences(), + $analysisNodeCollector->getFileInstantiations(), + $analysisNodeCollector->getFunctionNodes(), + $analysisNodeCollector->getAnonymousFunctionNodes(), + ); + + $this->analysisResultCache?->storeExtractionResult( + $files, + $this->analysisNodeCacheNamespace, + $extractionResult + ); + + return $extractionResult; + } +} diff --git a/src/Analyser/AnonymousClassNode.php b/src/Analyser/AnonymousClassNode.php index c1f85c57..c0a5d75a 100644 --- a/src/Analyser/AnonymousClassNode.php +++ b/src/Analyser/AnonymousClassNode.php @@ -4,27 +4,95 @@ namespace Boundwize\StructArmed\Analyser; +use function array_filter; + /** * An anonymous class declaration (`new class ... {}`). Anonymous classes never - * become ClassNodes — they cannot be referenced by name and no rule targets - * them directly — but the class they extend, the interfaces they implement, - * and the traits they use are still used within the scanned paths, which - * usage-aware rules must take into account. + * become ClassNodes — they cannot be referenced by name — so one is identified + * by its file and line, plus the named class-like and/or function it is + * declared in, and rules target it through + * {@see \Boundwize\StructArmed\Rule\AnonymousClassRuleInterface}. + * + * The class it extends, the interfaces it implements, and the traits it uses + * are still used within the scanned paths, which usage-aware rules must take + * into account: MustBeFinalRule must skip a class extended by an anonymous class. + * + * Its parent chain is resolved by the analyser like a named class's, so + * {@see extendsClass()} and {@see implementsInterface()} see transitive + * parents too. * - * The usage example is on MustBeFinalRule, which must skip if target class is extended by an anonymous class. + * Its members and body-level facts are collected like a named class's. The + * body-level facts of an anonymous class declared inside a class-like or + * named function are also counted on that enclosing node, exactly as the + * body of a closure is: a rule that only inspects the enclosing node keeps + * seeing everything the anonymous class does. Its members belong to the + * anonymous class alone. */ -final readonly class AnonymousClassNode +final class AnonymousClassNode { + use MemberQueryTrait; + use NodeQueryTrait; + use RecursiveParentsTrait; + + /** + * Scope label reported by {@see enclosingScopeName()} for an anonymous + * class declared outside any class-like or named function. + */ + public const FILE_SCOPE = 'file scope'; + + /** @var list */ + public readonly array $layers; + /** - * @param string[] $implements Interface names this anonymous class implements - * @param string[] $traits Trait names this anonymous class uses + * @param string[] $implements Interface names this anonymous class implements + * @param string[] $traits Trait names this anonymous class uses + * @param string|null $enclosingClassName Innermost named class-like this anonymous class is declared in + * @param string|null $enclosingFunctionName Innermost named function this anonymous class is declared in + * @param bool $hasEmptyParentheses Whether `()` follows `class` although no constructor argument + * is passed: `new class () {}` rather than `new class {}` + * @param list $layers Layer names this anonymous class belongs to; defaults to [$layer] + * @param list $parentClasses Direct and transitive parent class names + * @param list $parentInterfaces Direct and transitive implemented interface names + * @param list $dependencies Fully-qualified class, function, or constant dependencies + * @param MethodNode[] $methods Methods of this anonymous class + * @param ConstantNode[] $constants Constants of this anonymous class + * @param PropertyNode[] $properties Properties of this anonymous class + * @param string[] $functionCalls Functions called within this anonymous class + * @param string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) + * @param string[] $languageConstructs Language constructs used (exit, die, etc.) */ public function __construct( - public string $file, - public int $line, - public ?string $extends, - public array $implements = [], - public array $traits = [], + public readonly string $file, + public readonly int $line, + public readonly ?string $extends, + public readonly array $implements = [], + public readonly array $traits = [], + public readonly ?string $layer = null, + public readonly ?string $enclosingClassName = null, + public readonly ?string $enclosingFunctionName = null, + public readonly bool $hasEmptyParentheses = false, + array $layers = [], + public array $parentClasses = [], + public array $parentInterfaces = [], + public readonly bool $isReadonly = false, + public readonly array $dependencies = [], + public readonly array $methods = [], + public readonly array $constants = [], + public readonly array $properties = [], + public readonly array $functionCalls = [], + public readonly array $superglobals = [], + public readonly array $languageConstructs = [], ) { + $this->layers = $layers ?: array_filter([$this->layer]); + } + + /** + * Label of the innermost named scope declaring this anonymous class — + * the enclosing class-like, else the enclosing named function — or + * {@see self::FILE_SCOPE} for one declared in top-level procedural code. + */ + public function enclosingScopeName(): string + { + return $this->enclosingClassName ?? $this->enclosingFunctionName ?? self::FILE_SCOPE; } } diff --git a/src/Analyser/AnonymousFunctionNode.php b/src/Analyser/AnonymousFunctionNode.php new file mode 100644 index 00000000..ca89827e --- /dev/null +++ b/src/Analyser/AnonymousFunctionNode.php @@ -0,0 +1,79 @@ + ...`). It has no name of its own, so it is identified by its file + * and line, plus the named class-like and/or function it is declared in. + * + * The body-level facts of an anonymous function declared inside a class-like + * or named function are also counted on that enclosing node, exactly as the + * body of a method is counted on its class: a rule that only inspects the + * enclosing node keeps seeing everything the closure does. + */ +final readonly class AnonymousFunctionNode +{ + use NodeQueryTrait; + + /** + * Scope label reported by {@see enclosingScopeName()} for an anonymous + * function declared outside any class-like or named function. + */ + public const FILE_SCOPE = 'file scope'; + + /** @var list */ + public array $layers; + + /** + * @param string|null $enclosingClassName Innermost named class-like this anonymous function is declared in + * @param string|null $enclosingFunctionName Innermost named function this anonymous function is declared in + * @param bool $usesThis Whether the body (or a nested closure) reads `$this`; such a + * closure cannot be declared static + * @param list $dependencies Fully-qualified class, function, or constant dependencies + * @param string[] $functionCalls Functions called within this anonymous function + * @param string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) + * @param string[] $languageConstructs Language constructs used (exit, die, etc.) + * @param list $layers Layer names this anonymous function belongs to; defaults to [$layer] + */ + public function __construct( + public string $file, + public int $line, + public ?string $layer, + public bool $isArrowFunction = false, + public bool $isStatic = false, + public ?string $enclosingClassName = null, + public ?string $enclosingFunctionName = null, + public bool $usesThis = false, + public bool $hasReturnType = false, + public int $paramCount = 0, + public int $cyclomaticComplexity = 1, + public int $lineCount = 0, + public array $dependencies = [], + public array $functionCalls = [], + public array $superglobals = [], + public array $languageConstructs = [], + array $layers = [], + ) { + $this->layers = $layers ?: array_filter([$this->layer]); + } + + public function getType(): string + { + return $this->isArrowFunction ? 'Arrow function' : 'Closure'; + } + + /** + * Label of the innermost named scope declaring this anonymous function — + * the enclosing class-like, else the enclosing named function — or + * {@see self::FILE_SCOPE} for one declared in top-level procedural code. + */ + public function enclosingScopeName(): string + { + return $this->enclosingClassName ?? $this->enclosingFunctionName ?? self::FILE_SCOPE; + } +} diff --git a/src/Analyser/ClassCollector.php b/src/Analyser/ClassCollector.php deleted file mode 100644 index 578463fb..00000000 --- a/src/Analyser/ClassCollector.php +++ /dev/null @@ -1,1158 +0,0 @@ - true, - '_POST' => true, - '_REQUEST' => true, - '_SESSION' => true, - '_COOKIE' => true, - '_SERVER' => true, - '_ENV' => true, - '_FILES' => true, - 'GLOBALS' => true, - ]; - - private const KEYWORD_CONSTANTS = [ - 'true' => true, - 'false' => true, - 'null' => true, - ]; - - /** - * A string value shaped like a (possibly namespaced) class name, e.g. - * 'App\Contract' or 'stdClass'. Such values can reach `new $class` or - * `instanceof $class` at runtime, so they count as references. - */ - private const CLASS_LIKE_STRING_PATTERN = - '/^[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*+(?:\\\\[A-Za-z_\x80-\xff][A-Za-z0-9_\x80-\xff]*+)*+$/'; - - /** - * Method names of the ReflectionClass object-construction APIs. Calling - * one chained on a `new ReflectionClass()` receiver - * instantiates the reflected class. - */ - private const REFLECTION_CONSTRUCTION_METHODS = [ - 'newinstance' => true, - 'newinstanceargs' => true, - 'newinstancewithoutconstructor' => true, - 'newlazyghost' => true, - 'newlazyproxy' => true, - ]; - - /** - * Node classes counted as cyclomatic-complexity branches. The parser only - * ever instantiates these exact classes, so a single ::class hash lookup - * replaces an instanceof chain on the per-node hot path. - */ - private const COMPLEXITY_BRANCH_NODES = [ - If_::class => true, - ElseIf_::class => true, - For_::class => true, - Foreach_::class => true, - While_::class => true, - Do_::class => true, - Case_::class => true, - Catch_::class => true, - Ternary::class => true, - BooleanAnd::class => true, - BooleanOr::class => true, - LogicalAnd::class => true, - LogicalOr::class => true, - Coalesce::class => true, - AssignCoalesce::class => true, - NullsafeMethodCall::class => true, - NullsafePropertyFetch::class => true, - MatchArm::class => true, - ]; - - /** - * Node classes that map to a fixed language-construct name. Exit_ and - * Include_ are handled separately: their names depend on node data. - */ - private const LANGUAGE_CONSTRUCT_NODES = [ - Echo_::class => 'echo', - Print_::class => 'print', - Isset_::class => 'isset', - Empty_::class => 'empty', - Unset_::class => 'unset', - Eval_::class => 'eval', - List_::class => 'list', - ]; - - /** @var list */ - private array $nodes = []; - - /** @var list */ - private array $anonymousClassNodes = []; - - /** @var array> */ - private array $fileReferences = []; - - /** @var list */ - private array $currentFileReferences = []; - - /** - * Separator of a deferred instantiation marker, `@`, - * recorded when `new self()`, `new static()`, or `new parent()` cannot be - * resolved to a class name until every class has been collected. The `@` - * cannot occur in a class name, so a marker never collides with a real - * instantiation target. - * - * @see deferredInstantiationMarker() - * @see parseDeferredInstantiationMarker() - */ - private const DEFERRED_MARKER_SEPARATOR = '@'; - - /** @var array> */ - private array $fileInstantiations = []; - - /** @var list */ - private array $currentFileInstantiations = []; - - private readonly ConstExprEvaluator $constExprEvaluator; - - /** - * Stack of class-likes currently being entered, so `new self`, - * `new static`, and `new parent` instantiations can be resolved to the - * class names (or deferred markers) they target. Anonymous classes have - * no name to resolve self/static to, but their `extends` still resolves - * `parent`. - * - * @var list - */ - private array $activeClassLikeScopes = []; - - private string $currentFile = ''; - - /** @var list */ - private array $currentNamespaceUses = []; - - /** @var ClassLike[] */ - private array $fileClassLikes = []; - - /** @var array */ - private array $fileFunctions = []; - - /** @var array */ - private array $classLikeAnalysis = []; - - /** @var list */ - private array $activeClassLikeAnalyses = []; - - /** @var list */ - private array $activeMethodIds = []; - - /** @var array */ - private array $methodClassLikeAnalyses = []; - - public function __construct( - private readonly LayerResolverInterface $layerResolver - ) { - $this->constExprEvaluator = new ConstExprEvaluator(function (Expr $expr): string { - if ( - $expr instanceof ClassConstFetch - && $expr->name instanceof Identifier - && $expr->name->toLowerString() === 'class' - && $expr->class instanceof Name - ) { - $className = $this->resolveClassLikeName($expr->class); - - if ($className !== null) { - return $className; - } - } - - throw new ConstExprEvaluationException('Expression is not a resolvable class name.'); - }); - } - - public function setCurrentFile(string $file): void - { - $this->currentFile = $file; - $this->currentFileReferences = []; - $this->currentFileInstantiations = []; - $this->currentNamespaceUses = []; - $this->fileClassLikes = []; - $this->fileFunctions = []; - $this->classLikeAnalysis = []; - $this->activeClassLikeAnalyses = []; - $this->activeMethodIds = []; - $this->methodClassLikeAnalyses = []; - } - - /** @return list */ - public function getNodes(): array - { - return $this->nodes; - } - - /** @return list */ - public function getAnonymousClassNodes(): array - { - return $this->anonymousClassNodes; - } - - /** - * References to class-likes made outside any named class-like scope, per - * file — procedural functions, top-level statements, and top-level - * anonymous class bodies. - * - * @return array> - */ - public function getFileReferences(): array - { - return $this->fileReferences; - } - - /** - * Class-like instantiations (`new X`, with self/static/parent resolved to - * the class names they target), per file. `new` on an abstract class is - * fatal, so these are what an extended class needs to stay concrete. - * - * `new parent()` inside a trait is recorded as a marker instead, see - * {@see traitFromParentMarker()}. - * - * @return array> - */ - public function getFileInstantiations(): array - { - return $this->fileInstantiations; - } - - /** - * Marker recorded in place of a class name for `new ()` whose - * target depends on classes not yet collected: `self`, `static`, and - * `parent` inside a trait resolve against each class using the trait, - * and `static` inside a class also covers its descendants. - * - * @param 'self'|'static'|'parent' $keyword - */ - public static function deferredInstantiationMarker(string $keyword, string $classLikeName): string - { - return $keyword . self::DEFERRED_MARKER_SEPARATOR . $classLikeName; - } - - /** - * The keyword and class-like name carried by a deferred instantiation - * marker, or null when the instantiation is a plain class name. - * - * @return array{0: 'self'|'static'|'parent', 1: string}|null - */ - public static function parseDeferredInstantiationMarker(string $instantiation): ?array - { - $separatorPosition = strpos($instantiation, self::DEFERRED_MARKER_SEPARATOR); - - if ($separatorPosition === false) { - return null; - } - - $keyword = substr($instantiation, 0, $separatorPosition); - - if (! in_array($keyword, ['self', 'static', 'parent'], true)) { - return null; - } - - return [$keyword, substr($instantiation, $separatorPosition + 1)]; - } - - public function enterNode(Node $node): null - { - // The scope-tracking node types are all statements, so the far more - // frequent expression/name/identifier nodes skip their checks with a - // single instanceof. - if ($node instanceof Stmt) { - if ($node instanceof Namespace_) { - $this->currentNamespaceUses = []; - - return null; - } - - if ($node instanceof Use_) { - foreach ($node->uses as $use) { - $this->currentNamespaceUses[] = $use->name->toString(); - } - - return null; - } - - if ($node instanceof GroupUse) { - $prefix = $node->prefix->toString(); - - foreach ($node->uses as $use) { - $this->currentNamespaceUses[] = $prefix . '\\' . $use->name->toString(); - } - - return null; - } - - if ($node instanceof Function_) { - if (isset($node->namespacedName)) { - $this->fileFunctions[$node->namespacedName->toString()] = true; - } - - return null; - } - - if ($node instanceof ClassLike) { - $this->activeClassLikeScopes[] = $this->createClassLikeScope($node); - - if ($node->name instanceof Identifier) { - $this->startClassLikeAnalysis($node); - } - - return null; - } - - if ($node instanceof ClassMethod) { - $this->startMethodAnalysis($node); - - return null; - } - } - - $this->collectNodeAnalysis($node); - - return null; - } - - public function leaveNode(Node $node): null - { - // Both instantiation handlers run on leave, once the NameResolver - // has resolved the nested name nodes (e.g. Base::class inside the - // class expression). They only match expressions, and ClassMethod / - // ClassLike are statements, so one instanceof splits the two groups. - if ($node instanceof Expr) { - // Instantiations are tracked separately from plain references: - // `new` on an abstract class is fatal, so instantiation is the one - // usage that requires an extended class to stay concrete — type - // hints, instanceof checks, and ::class constants all keep working - // once a class becomes abstract. - if ($node instanceof New_) { - $this->collectInstantiation($node); - - return null; - } - - // A ReflectionClass construction call instantiates the reflected - // class when the reflection target is statically resolvable. The - // `new` receiver is checked first: it is the rare shape, so the - // common method call skips the name lowering entirely. - if ( - ($node instanceof MethodCall || $node instanceof NullsafeMethodCall) - && $node->var instanceof New_ - && $node->name instanceof Identifier - && isset(self::REFLECTION_CONSTRUCTION_METHODS[$node->name->toLowerString()]) - ) { - $this->collectReflectionInstantiation($node->var); - } - - return null; - } - - if ($node instanceof ClassMethod) { - $this->finishMethodAnalysis($node); - - return null; - } - - if (! $node instanceof ClassLike) { - return null; - } - - array_pop($this->activeClassLikeScopes); - - if (! $node->name instanceof Identifier) { - // Anonymous classes never become ClassNodes, but the class they - // extend, the interfaces they implement, and the traits they use - // are still used within the scanned paths. - if ($node instanceof Class_) { - $this->anonymousClassNodes[] = new AnonymousClassNode( - file: $this->currentFile, - line: $node->getStartLine(), - extends: $node->extends instanceof Name ? $node->extends->toString() : null, - implements: $this->collectImplements($node), - traits: $this->collectTraits($node), - ); - } - - return null; - } - - $this->fileClassLikes[] = $node; - array_pop($this->activeClassLikeAnalyses); - - return null; - } - - /** @param Node[] $nodes */ - public function afterTraverse(array $nodes): null - { - foreach ($this->fileClassLikes as $fileClassLike) { - $this->collectClassLike($fileClassLike); - } - - if ($this->currentFileReferences !== []) { - $this->fileReferences[$this->currentFile] = array_values(array_unique($this->currentFileReferences)); - $this->currentFileReferences = []; - } - - if ($this->currentFileInstantiations !== []) { - $this->fileInstantiations[$this->currentFile] = array_values( - array_unique($this->currentFileInstantiations) - ); - $this->currentFileInstantiations = []; - } - - $this->fileClassLikes = []; - $this->classLikeAnalysis = []; - $this->activeClassLikeAnalyses = []; - $this->activeClassLikeScopes = []; - $this->activeMethodIds = []; - $this->methodClassLikeAnalyses = []; - - return null; - } - - private function startClassLikeAnalysis(ClassLike $classLike): void - { - $classLikeId = spl_object_id($classLike); - $classLikeAnalysis = new ClassLikeAnalysis(); - - $classLikeAnalysis->dependencies = $this->currentNamespaceUses; - - $this->classLikeAnalysis[$classLikeId] = $classLikeAnalysis; - $this->activeClassLikeAnalyses[] = $classLikeAnalysis; - - foreach ($classLike->getMethods() as $classMethod) { - $this->methodClassLikeAnalyses[spl_object_id($classMethod)] = $classLikeAnalysis; - } - } - - private function startMethodAnalysis(ClassMethod $classMethod): void - { - $methodId = spl_object_id($classMethod); - - $analysis = $this->methodClassLikeAnalyses[$methodId] ?? null; - - if (! $analysis instanceof ClassLikeAnalysis) { - return; - } - - $this->activeMethodIds[] = $methodId; - - $analysis->complexityByMethodId[$methodId] = 1; - } - - private function finishMethodAnalysis(ClassMethod $classMethod): void - { - if (! isset($this->methodClassLikeAnalyses[spl_object_id($classMethod)])) { - return; - } - - array_pop($this->activeMethodIds); - } - - private function collectNodeAnalysis(Node $node): void - { - // A class-name-shaped string literal may feed `new $class`, - // `$obj instanceof $class`, class_exists(), container ids, and so on. - // Whether it appears inside a class-like or in procedural code, treat - // it as a file-level reference so the named class-like stays alive. - if ($node instanceof String_) { - // A leading `\` is a valid fully-qualified spelling - // (`'\App\Contract'`); strip it so the stored name matches the - // ClassNode::$className form used for usage lookups. - $value = $this->stripLeadingNamespaceSeparator($node->value); - - if ( - preg_match(self::CLASS_LIKE_STRING_PATTERN, $value) === 1 - && ! isset(self::KEYWORD_CONSTANTS[strtolower($value)]) - ) { - $this->currentFileReferences[] = $value; - } - - return; - } - - if ($node instanceof FullyQualified) { - $name = $node->toString(); - - if (isset(self::KEYWORD_CONSTANTS[strtolower($name)])) { - return; - } - - if ($this->activeClassLikeAnalyses === []) { - // Outside any named class-like scope — procedural functions, - // top-level statements, top-level anonymous class bodies — a - // class-like reference still keeps the referenced class-like - // alive. - $this->currentFileReferences[] = $name; - - return; - } - - $this->addDependency($name); - - return; - } - - if ($this->activeClassLikeAnalyses === []) { - return; - } - - // Branch nodes (conditions, loops, boolean operators) are among the - // most frequent remaining node types, so they dispatch on one hash - // lookup before the rarer per-type checks below. - if (isset(self::COMPLEXITY_BRANCH_NODES[$node::class])) { - foreach ($this->activeMethodIds as $activeMethodId) { - $this->methodClassLikeAnalyses[$activeMethodId]->complexityByMethodId[$activeMethodId]++; - } - - return; - } - - if ($node instanceof Variable) { - if (is_string($node->name) && isset(self::SUPERGLOBALS[$node->name])) { - $this->addSuperglobal('$' . $node->name); - } - - return; - } - - if ($node instanceof FuncCall) { - if ($node->name instanceof Name) { - $functionName = $node->name->toLowerString(); - - // PHP 8.4 generalized exit/die (e.g. named arguments) parse as - // FuncCall instead of Exit_, but remain language constructs - if ($functionName === 'exit' || $functionName === 'die') { - $this->addLanguageConstruct($functionName); - } else { - $this->addFunctionCallName($node->name); - } - } - - return; - } - - if ($node instanceof Exit_) { - $this->addLanguageConstruct( - $node->getAttribute('kind') === Exit_::KIND_DIE - ? 'die' - : 'exit' - ); - - return; - } - - if ($node instanceof Include_) { - $this->addLanguageConstruct(match ($node->type) { - Include_::TYPE_REQUIRE => 'require', - Include_::TYPE_INCLUDE_ONCE => 'include_once', - Include_::TYPE_REQUIRE_ONCE => 'require_once', - default => 'include', - }); - - return; - } - - $languageConstruct = self::LANGUAGE_CONSTRUCT_NODES[$node::class] ?? null; - - if ($languageConstruct !== null) { - $this->addLanguageConstruct($languageConstruct); - } - } - - private function collectInstantiation(New_ $new): void - { - $class = $new->class; - - if ($class instanceof Name) { - $className = $this->resolveClassLikeName($class); - - if ($className !== null) { - $this->currentFileInstantiations[] = $className; - } - - return; - } - - // Anonymous classes (`new class {}`) are tracked as - // AnonymousClassNodes; constant class expressions may still resolve - // below. Runtime-fed dynamic instantiations (`new \$class` from a - // parameter, unserialize(), containers) are part of the documented - // scanned-code boundary and resolve to nothing. - if (! $class instanceof Expr) { - return; - } - - // `new (X::class)` / `new ('App\X')` constant class expressions. - $className = $this->resolveClassNameExpr($class); - - if ($className !== null) { - $this->currentFileInstantiations[] = $className; - } - } - - /** - * Resolve a class-like name node to a fully qualified name (or deferred - * marker): either it is already fully qualified, or it is a - * self/static/parent keyword resolved against the enclosing class-like - * scope. Returns null when there is no scope to resolve against. - */ - private function resolveClassLikeName(Name $name): ?string - { - if ($name instanceof FullyQualified) { - return $name->toString(); - } - - // After name resolution only self, static, and parent survive as - // plain names. - $scope = end($this->activeClassLikeScopes); - - if ($scope === false) { - return null; - } - - return $scope[$name->toLowerString()] ?? null; - } - - /** - * A trait is never instantiated itself: `self`, `static`, and `parent` - * target whichever class uses the trait, and `static` in a class also - * targets its descendants. Both are only known once every class has been - * collected, so those scope entries carry a marker the analyser resolves - * later, in place of a class name. - * - * @return array{self: string|null, static: string|null, parent: string|null} - */ - private function createClassLikeScope(ClassLike $classLike): array - { - $parent = $classLike instanceof Class_ && $classLike->extends instanceof Name - ? $classLike->extends->toString() - : null; - - if (! $classLike->name instanceof Identifier) { - return ['self' => null, 'static' => null, 'parent' => $parent]; - } - - $name = $this->resolveClassName($classLike); - - if ($classLike instanceof Trait_) { - return [ - 'self' => self::deferredInstantiationMarker('self', $name), - 'static' => self::deferredInstantiationMarker('static', $name), - 'parent' => self::deferredInstantiationMarker('parent', $name), - ]; - } - - return [ - 'self' => $name, - 'static' => self::deferredInstantiationMarker('static', $name), - 'parent' => $parent, - ]; - } - - /** - * Resolve `new ReflectionClass()` to the - * reflected class name, or null for anything else. - */ - private function resolveReflectionTarget(New_ $new): ?string - { - if (! $new->class instanceof Name) { - return null; - } - - if (strcasecmp($new->class->toString(), 'ReflectionClass') !== 0) { - return null; - } - - $firstArg = $new->args[0] ?? null; - - if (! $firstArg instanceof Arg) { - return null; - } - - return $this->resolveClassNameExpr($firstArg->value); - } - - /** - * Record the reflected class as instantiated when a construction method is - * called chained on a `new` receiver that is a resolvable ReflectionClass. - * Anything else — variable-held reflections, runtime-named targets — - * records nothing: that is part of the documented scanned-code boundary. - */ - private function collectReflectionInstantiation(New_ $new): void - { - $reflectionTarget = $this->resolveReflectionTarget($new); - - if ($reflectionTarget !== null) { - $this->currentFileInstantiations[] = $reflectionTarget; - } - } - - /** - * Evaluate a constant expression to a class-name string: 'App\X' literals, - * X::class (including self/static/parent::class, which may yield a - * deferred marker), and concatenations of those. Anything depending on - * runtime values resolves to null. - */ - private function resolveClassNameExpr(Expr $expr): ?string - { - if (! $expr instanceof String_ && ! $expr instanceof ClassConstFetch && ! $expr instanceof Concat) { - return null; - } - - try { - /** @var string $value */ - $value = $this->constExprEvaluator->evaluateSilently($expr); - } catch (ConstExprEvaluationException) { - return null; - } - - $marker = self::parseDeferredInstantiationMarker($value); - - if ($marker === null) { - $value = $this->stripLeadingNamespaceSeparator($value); - } - - return preg_match(self::CLASS_LIKE_STRING_PATTERN, $marker[1] ?? $value) === 1 - ? $value - : null; - } - - /** - * Statically resolve a backed enum case value. Returns null for a pure - * enum case and for values that depend on symbols outside the expression - * (global constants, other class constants), which the analyser cannot - * evaluate. - */ - private function resolveEnumCaseValue(?Expr $expr): int|string|null - { - if (! $expr instanceof Expr) { - return null; - } - - try { - $value = $this->constExprEvaluator->evaluateSilently($expr); - } catch (ConstExprEvaluationException) { - return null; - } - - return is_int($value) || is_string($value) ? $value : null; - } - - /** - * `'\App\X'` and `'App\X'` name the same class; the collector stores the - * latter form so usage keys line up with ClassNode::$className. - */ - private function stripLeadingNamespaceSeparator(string $name): string - { - return str_starts_with($name, '\\') ? substr($name, 1) : $name; - } - - private function addDependency(string $dependency): void - { - foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { - $activeClassLikeAnalysis->dependencies[] = $dependency; - } - } - - private function addFunctionCallName(Name $functionCallName): void - { - foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { - $activeClassLikeAnalysis->functionCallNames[] = $functionCallName; - } - } - - private function addSuperglobal(string $superglobal): void - { - foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { - $activeClassLikeAnalysis->superglobals[] = $superglobal; - } - } - - private function addLanguageConstruct(string $languageConstruct): void - { - foreach ($this->activeClassLikeAnalyses as $activeClassLikeAnalysis) { - $activeClassLikeAnalysis->languageConstructs[] = $languageConstruct; - } - } - - private function collectClassLike(ClassLike $classLike): void - { - $classLikeId = spl_object_id($classLike); - $analysis = $this->collectClassLikeAnalysis($classLikeId); - $className = $this->resolveClassName($classLike); - $layers = $this->layerResolver->resolveAll($className, $this->currentFile); - $layer = $this->layerResolver->resolve($className, $this->currentFile); - $implements = $this->collectImplements($classLike); - $interfaceExtends = $this->collectInterfaceExtends($classLike); - - [$traits, $constants, $properties, $methods, $enumCases] = $this->collectMembers( - $classLike, - $analysis['complexityByMethodId'] - ); - - $this->nodes[] = new ClassNode( - className: $className, - file: $this->currentFile, - line: $classLike->getStartLine(), - layer: $layer, - extends: $classLike instanceof Class_ && $classLike->extends instanceof Name - ? $classLike->extends->toString() - : null, - isAbstract: $classLike instanceof Class_ && $classLike->isAbstract(), - isFinal: $classLike instanceof Class_ && $classLike->isFinal(), - isInterface: $classLike instanceof Interface_, - isReadonly: $classLike instanceof Class_ && $classLike->isReadonly(), - isTrait: $classLike instanceof Trait_, - dependencies: $analysis['dependencies'], - implements: $implements, - traits: $traits, - methods: $methods, - constants: $constants, - properties: $properties, - functionCalls: $analysis['functionCalls'], - superglobals: $analysis['superglobals'], - languageConstructs: $analysis['languageConstructs'], - layers: $layers, - isEnum: $classLike instanceof Enum_, - interfaceExtends: $interfaceExtends, - enumCases: $enumCases, - enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instanceof Identifier - ? $classLike->scalarType->toLowerString() - : null, - ); - } - - /** - * Collect traits, constants, properties, methods, and enum cases in a single - * pass over the class-like statements instead of one loop per member kind. - * - * @param array $complexityByMethodId - * @return array{0: string[], 1: ConstantNode[], 2: PropertyNode[], 3: MethodNode[], 4: EnumCaseNode[]} - */ - private function collectMembers(ClassLike $classLike, array $complexityByMethodId): array - { - $isInterface = $classLike instanceof Interface_; - $traits = []; - $constants = []; - $properties = []; - $methods = []; - $enumCases = []; - - foreach ($classLike->stmts as $stmt) { - if ($stmt instanceof TraitUse) { - if ($isInterface) { - continue; - } - - foreach ($stmt->traits as $trait) { - $traits[] = $trait->toString(); - } - - continue; - } - - if ($stmt instanceof ClassConst) { - $visibility = $this->resolveVisibilityName($stmt); - $hasExplicitVisibility = VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags); - - foreach ($stmt->consts as $const) { - $constants[] = new ConstantNode( - name: (string) $const->name, - visibility: $visibility, - hasExplicitVisibility: $hasExplicitVisibility, - line: $const->getStartLine(), - ); - } - - continue; - } - - if ($stmt instanceof Property) { - $visibility = $this->resolveVisibilityName($stmt); - $hasExplicitVisibility = VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags); - - foreach ($stmt->props as $prop) { - $properties[] = new PropertyNode( - name: (string) $prop->name, - visibility: $visibility, - hasExplicitVisibility: $hasExplicitVisibility, - line: $prop->getStartLine(), - ); - } - - continue; - } - - if ($stmt instanceof EnumCase) { - $enumCases[] = new EnumCaseNode( - name: (string) $stmt->name, - line: $stmt->getStartLine(), - value: $this->resolveEnumCaseValue($stmt->expr), - ); - - continue; - } - - if ($stmt instanceof ClassMethod) { - $methods[] = new MethodNode( - name: (string) $stmt->name, - visibility: $this->resolveVisibilityName($stmt), - hasReturnType: $stmt->returnType instanceof Node, - isStatic: $stmt->isStatic(), - paramCount: count($stmt->params), - cyclomaticComplexity: $complexityByMethodId[spl_object_id($stmt)] ?? 1, - lineCount: $this->calculateMethodLineCount($stmt), - hasExplicitVisibility: VisibilityFlagChecker::hasExplicitVisibilityFlag($stmt->flags), - line: $stmt->getStartLine(), - isMagic: $stmt->isMagic(), - ); - - if ($stmt->name->toLowerString() !== '__construct') { - continue; - } - - foreach ($stmt->params as $param) { - if ( - ! $param->isPromoted() - || ! $param->var instanceof Variable - || ! is_string($param->var->name) - ) { - continue; - } - - $properties[] = new PropertyNode( - name: (string) $param->var->name, - visibility: $this->resolveVisibilityName($param), - hasExplicitVisibility: VisibilityFlagChecker::hasExplicitVisibilityFlag($param->flags), - line: $param->getStartLine(), - ); - } - } - } - - return [$traits, $constants, $properties, $methods, $enumCases]; - } - - private function resolveClassName(ClassLike $classLike): string - { - return isset($classLike->namespacedName) - ? $classLike->namespacedName->toString() - : (string) $classLike->name; - } - - /** - * @return array{ - * dependencies: list, - * functionCalls: string[], - * superglobals: string[], - * languageConstructs: string[], - * complexityByMethodId: array - * } - */ - private function collectClassLikeAnalysis(int $classLikeId): array - { - $analysis = $this->classLikeAnalysis[$classLikeId] ?? new ClassLikeAnalysis(); - $functionCalls = []; - - foreach ($analysis->functionCallNames as $functionCallName) { - $functionCalls[] = $this->resolveFunctionName($functionCallName); - } - - return [ - 'dependencies' => array_values(array_unique($analysis->dependencies)), - 'functionCalls' => array_values(array_unique($functionCalls)), - 'superglobals' => array_values(array_unique($analysis->superglobals)), - 'languageConstructs' => array_values(array_unique($analysis->languageConstructs)), - 'complexityByMethodId' => $analysis->complexityByMethodId, - ]; - } - - private function resolveFunctionName(Name $name): string - { - $functionName = $name->toString(); - - if ($name instanceof FullyQualified) { - return $functionName; - } - - $namespacedName = $name->getAttribute('namespacedName'); - - if ($namespacedName instanceof Name) { - $namespacedNameString = $namespacedName->toString(); - - if (isset($this->fileFunctions[$namespacedNameString])) { - return $namespacedNameString; - } - } - - return $functionName; - } - - /** - * @return string[] - */ - private function collectImplements(ClassLike $classLike): array - { - $interfaces = []; - - if ($classLike instanceof Class_ || $classLike instanceof Enum_) { - foreach ($classLike->implements as $interface) { - $interfaces[] = $interface->toString(); - } - } - - return $interfaces; - } - - /** - * @return string[] - */ - private function collectInterfaceExtends(ClassLike $classLike): array - { - if (! $classLike instanceof Interface_) { - return []; - } - - $parents = []; - - foreach ($classLike->extends as $parent) { - $parents[] = $parent->toString(); - } - - return $parents; - } - - /** - * Traits used by an anonymous class; named class-likes collect theirs in - * collectMembers(). - * - * @return string[] - */ - private function collectTraits(Class_ $class): array - { - $traits = []; - - foreach ($class->stmts as $stmt) { - if (! $stmt instanceof TraitUse) { - continue; - } - - foreach ($stmt->traits as $trait) { - $traits[] = $trait->toString(); - } - } - - return $traits; - } - - private function resolveVisibilityName(ClassMethod|ClassConst|Property|Param $node): string - { - if ($node->isProtected()) { - return 'protected'; - } - - if ($node->isPrivate()) { - return 'private'; - } - - return 'public'; - } - - private function calculateMethodLineCount(ClassMethod $classMethod): int - { - if ($classMethod->stmts === null || $classMethod->stmts === []) { - return 0; - } - - $lastIndex = count($classMethod->stmts) - 1; - return $classMethod->stmts[$lastIndex]->getEndLine() - $classMethod->stmts[0]->getStartLine() + 1; - } -} diff --git a/src/Analyser/ClassLikeAnalysis.php b/src/Analyser/ClassLikeAnalysis.php index e35731d2..ec591251 100644 --- a/src/Analyser/ClassLikeAnalysis.php +++ b/src/Analyser/ClassLikeAnalysis.php @@ -7,22 +7,42 @@ use PhpParser\Node\Name; /** + * Facts collected while traversing a class-like: body-level references + * plus its members, each recorded as the traverser passes the declaring node. + * * @internal */ final class ClassLikeAnalysis { - /** @var list */ + /** @var array */ public array $dependencies = []; /** @var list */ public array $functionCallNames = []; - /** @var string[] */ + /** @var array */ public array $superglobals = []; - /** @var string[] */ + /** @var array */ public array $languageConstructs = []; - /** @var array */ - public array $complexityByMethodId = []; + /** @var string[] */ + public array $traits = []; + + /** @var ConstantNode[] */ + public array $constants = []; + + /** @var PropertyNode[] */ + public array $properties = []; + + /** @var MethodNode[] */ + public array $methods = []; + + /** @var EnumCaseNode[] */ + public array $enumCases = []; + + public function __construct( + public readonly bool $isInterface, + ) { + } } diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index 825fc443..9dfa9cfe 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -5,17 +5,18 @@ namespace Boundwize\StructArmed\Analyser; use function array_filter; -use function in_array; use function preg_match; -use function rtrim; use function str_ends_with; use function str_starts_with; -use function strcasecmp; use function strrpos; use function substr; final class ClassNode { + use MemberQueryTrait; + use NodeQueryTrait; + use RecursiveParentsTrait; + /** @var list */ public readonly array $layers; @@ -93,16 +94,6 @@ public function getType(): string return 'Class'; } - /** - * @param list $parentClasses - * @param list $parentInterfaces - */ - public function setRecursiveParents(array $parentClasses, array $parentInterfaces): void - { - $this->parentClasses = $parentClasses; - $this->parentInterfaces = $parentInterfaces; - } - /** * Whether another scanned class extends this class. Computed by the analyser * for rules implementing ExtendedClassAwareRuleInterface; false otherwise. @@ -139,18 +130,9 @@ public function setReferenced(bool $isReferenced): void * `new self`/`new static`/`new parent` resolving to it. Instantiation is * the one usage that requires a class to stay concrete. Computed by the * analyser when a usage-aware rule is active; false otherwise. - * - * Only a concrete named class can be an instantiation target — `new` on - * an abstract class, interface, trait, or enum is fatal — so marking any - * other class-like as instantiated is ignored. (Anonymous classes never - * become ClassNodes in the first place.) */ public function setInstantiated(bool $isInstantiated): void { - if ($isInstantiated && (! $this->isClass() || $this->isAbstract)) { - return; - } - $this->isInstantiated = $isInstantiated; } @@ -163,11 +145,6 @@ public function shortName(): string : substr($this->className, $position + 1); } - public function isInLayer(string $layer): bool - { - return in_array($layer, $this->layers, true); - } - public function isClass(): bool { return ! $this->isInterface && ! $this->isTrait && ! $this->isEnum; @@ -188,26 +165,9 @@ public function nameMatches(string $pattern, bool $isFullName = false): bool return (bool) preg_match($pattern, $isFullName ? $this->className : $this->shortName()); } - public function dependsOn(string $class): bool - { - return in_array($class, $this->dependencies, true); - } - - public function dependsOnNamespace(string $namespace): bool - { - $prefix = rtrim($namespace, '\\') . '\\'; - - foreach ($this->dependencies as $dependency) { - if (str_starts_with($dependency, $prefix)) { - return true; - } - } - - return false; - } - /** * Implemented directly, extended directly (for interfaces), or via any parent class or interface. + * Overrides the trait method to also look at `$interfaceExtends`, which only a named interface has. */ public function implementsInterface(string $interface): bool { @@ -215,71 +175,4 @@ public function implementsInterface(string $interface): bool || $this->matchesAnyClassLike($interface, $this->interfaceExtends) || $this->matchesAnyClassLike($interface, $this->parentInterfaces); } - - public function extendsClass(string $class): bool - { - if ($this->extends !== null && strcasecmp($this->extends, $class) === 0) { - return true; - } - - return $this->matchesAnyClassLike($class, $this->parentClasses); - } - - /** - * Class-like names are case-insensitive in PHP. This matching is kept - * separate from dependencies, which may also contain constants. - * - * @param string[] $classLikes - */ - private function matchesAnyClassLike(string $needle, array $classLikes): bool - { - foreach ($classLikes as $classLike) { - if (strcasecmp($classLike, $needle) === 0) { - return true; - } - } - - return false; - } - - public function callsFunction(string $function): bool - { - foreach ($this->functionCalls as $functionCall) { - if (strcasecmp($functionCall, $function) === 0) { - return true; - } - } - - return false; - } - - public function usesLanguageConstruct(string $construct): bool - { - if (in_array($construct, $this->languageConstructs, true)) { - return true; - } - - // `die` is a pure alias of `exit`, so banning either spelling catches both. - return match ($construct) { - 'exit' => in_array('die', $this->languageConstructs, true), - 'die' => in_array('exit', $this->languageConstructs, true), - default => false, - }; - } - - public function accessesSuperglobals(): bool - { - return $this->superglobals !== []; - } - - public function constructorParamCount(): int - { - foreach ($this->methods as $method) { - if ($method->isConstructor()) { - return $method->paramCount; - } - } - - return 0; - } } diff --git a/src/Analyser/ClassNodeExtractor.php b/src/Analyser/ClassNodeExtractor.php deleted file mode 100644 index 721d30c3..00000000 --- a/src/Analyser/ClassNodeExtractor.php +++ /dev/null @@ -1,64 +0,0 @@ -fileAnalysisProvider = $fileAnalysisProvider ?? new FileAnalysisProvider(); - } - - /** @param list $files */ - public function extract( - array $files, - ?ProgressHandlerInterface $progressHandler = null, - bool $withFileAnalysis = true, - ): ExtractionResult { - $classCollector = new ClassCollector($this->layerResolver); - $nodeTraverser = new NodeTraverser(new NameResolver(), $classCollector); - $fileAnalyses = []; - - foreach ($files as $file) { - try { - $ast = $this->fileAnalysisProvider->ast($file, $withFileAnalysis); - - if ($withFileAnalysis) { - $fileAnalyses[$file] = $this->fileAnalysisProvider->analyse($file); - } - - if ($ast === null || $ast === []) { - continue; - } - - $classCollector->setCurrentFile($file); - $nodeTraverser->traverse($ast); - } finally { - if ($withFileAnalysis) { - $this->fileAnalysisProvider->releaseAst($file); - } - - $progressHandler?->advance($file); - } - } - - return new ExtractionResult( - $classCollector->getNodes(), - $fileAnalyses, - $classCollector->getAnonymousClassNodes(), - $classCollector->getFileReferences(), - $classCollector->getFileInstantiations(), - ); - } -} diff --git a/src/Analyser/ExtractionResult.php b/src/Analyser/ExtractionResult.php index 252641e6..94efb6c0 100644 --- a/src/Analyser/ExtractionResult.php +++ b/src/Analyser/ExtractionResult.php @@ -11,9 +11,11 @@ * @param array $fileAnalyses * @param list $anonymousClassNodes * @param array> $fileReferences Class-like references made outside any - * named class-like scope, per file + * class-like scope, per file * @param array> $fileInstantiations Class-like instantiations (`new X`, * with self/static/parent resolved), per file + * @param list $functionNodes + * @param list $anonymousFunctionNodes */ public function __construct( public array $classNodes, @@ -21,6 +23,8 @@ public function __construct( public array $anonymousClassNodes = [], public array $fileReferences = [], public array $fileInstantiations = [], + public array $functionNodes = [], + public array $anonymousFunctionNodes = [], ) { } } diff --git a/src/Analyser/FileAnalysis.php b/src/Analyser/FileAnalysis.php index 241a3dfe..3297a14d 100644 --- a/src/Analyser/FileAnalysis.php +++ b/src/Analyser/FileAnalysis.php @@ -6,6 +6,16 @@ final readonly class FileAnalysis { + /** + * @param list $nonCanonicalKeywordConstants `true`, `false`, and `null` + * fetches not spelled in lowercase, + * as [line, spelling as written]; a + * leading `\` marks a fully + * qualified form such as `\TRUE`. + * @param list $numericLiterals Numeric literals as + * [line, spelling as written, + * evaluated value]. + */ public function __construct( public string $file, public bool $hasUtf8Bom, @@ -15,6 +25,8 @@ public function __construct( public bool $declaresSymbols, public bool $hasSideEffects, public int $sideEffectLine, + public array $nonCanonicalKeywordConstants = [], + public array $numericLiterals = [], ) { } } diff --git a/src/Analyser/FileAnalysisProvider.php b/src/Analyser/FileAnalysisProvider.php index 2c1598c5..6f036081 100644 --- a/src/Analyser/FileAnalysisProvider.php +++ b/src/Analyser/FileAnalysisProvider.php @@ -45,6 +45,7 @@ use PhpParser\Node\Stmt\Use_; use PhpParser\Parser; use PhpParser\ParserFactory; +use PhpParser\Token; use function array_key_exists; use function array_keys; @@ -131,8 +132,19 @@ private static function normaliseAnalyses(array $analyses): array return $normalisedAnalyses; } - public function analyse(string $file): FileAnalysis - { + /** + * @param list $nonCanonicalKeywordConstants Keyword constant spellings the + * analysis-node traversal recorded + * for the file; the provider never + * walks the AST for them itself. + * @param list $numericLiterals Numeric literals recorded by the + * same analysis-node traversal. + */ + public function analyse( + string $file, + array $nonCanonicalKeywordConstants = [], + array $numericLiterals = [], + ): FileAnalysis { $file = Path::normalise($file, canonicalise: true); if (isset($this->analyses[$file])) { @@ -157,6 +169,8 @@ public function analyse(string $file): FileAnalysis declaresSymbols: $fileState['declaresSymbols'], hasSideEffects: $fileState['hasSideEffects'], sideEffectLine: $fileState['sideEffectLine'], + nonCanonicalKeywordConstants: $nonCanonicalKeywordConstants, + numericLiterals: $numericLiterals, ); $this->analyses[$file] = $fileAnalysis; @@ -189,6 +203,18 @@ public function ast(string $file, bool $retainForAnalysis = true): ?array return $this->parse($file); } + /** + * The token stream of the file {@see ast()} parsed last, for the facts an + * AST does not carry. PHP-Parser keeps it until its next parse, so it is + * read right after ast(); the provider itself retains no token arrays. + * + * @return array + */ + public function tokens(): array + { + return $this->parser->getTokens(); + } + /** * Parses an already normalised file that has neither a cached AST nor an * analysis, recording its AST, validity and invalid PHP tag line in one pass. diff --git a/src/Analyser/FunctionLikeAnalysis.php b/src/Analyser/FunctionLikeAnalysis.php new file mode 100644 index 00000000..a7aa9d57 --- /dev/null +++ b/src/Analyser/FunctionLikeAnalysis.php @@ -0,0 +1,41 @@ + */ + public array $dependencies = []; + + /** @var list */ + public array $functionCallNames = []; + + /** @var array */ + public array $superglobals = []; + + /** @var array */ + public array $languageConstructs = []; + + public int $cyclomaticComplexity = 1; + + public bool $usesThis = false; + + public function __construct( + public readonly FunctionLike $functionLike, + public readonly ?string $enclosingClassName, + public readonly ?string $enclosingFunctionName, + ) { + } +} diff --git a/src/Analyser/FunctionNode.php b/src/Analyser/FunctionNode.php new file mode 100644 index 00000000..d852b32d --- /dev/null +++ b/src/Analyser/FunctionNode.php @@ -0,0 +1,75 @@ + */ + public array $layers; + + /** + * @param string $functionName Fully-qualified function name + * @param list $dependencies Fully-qualified class, function, or constant dependencies + * @param string[] $functionCalls Functions called within this function + * @param string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) + * @param string[] $languageConstructs Language constructs used (exit, die, etc.) + * @param list $layers All layer names this function belongs to; defaults to [$layer] + */ + public function __construct( + public string $functionName, + public string $file, + public int $line, + public ?string $layer, + public bool $hasReturnType = false, + public int $paramCount = 0, + public int $cyclomaticComplexity = 1, + public int $lineCount = 0, + public array $dependencies = [], + public array $functionCalls = [], + public array $superglobals = [], + public array $languageConstructs = [], + array $layers = [], + ) { + $this->layers = $layers ?: array_filter([$this->layer]); + } + + public function shortName(): string + { + $position = strrpos($this->functionName, '\\'); + + return $position === false + ? $this->functionName + : substr($this->functionName, $position + 1); + } + + public function nameEndsWith(string $suffix): bool + { + return str_ends_with($this->shortName(), $suffix); + } + + public function nameStartsWith(string $prefix): bool + { + return str_starts_with($this->shortName(), $prefix); + } + + public function nameMatches(string $pattern, bool $isFullName = false): bool + { + return (bool) preg_match($pattern, $isFullName ? $this->functionName : $this->shortName()); + } +} diff --git a/src/Analyser/MemberQueryTrait.php b/src/Analyser/MemberQueryTrait.php new file mode 100644 index 00000000..dd4edc95 --- /dev/null +++ b/src/Analyser/MemberQueryTrait.php @@ -0,0 +1,29 @@ +methods as $method) { + if ($method->isConstructor()) { + return $method->paramCount; + } + } + + return 0; + } +} diff --git a/src/Analyser/NodeQueryTrait.php b/src/Analyser/NodeQueryTrait.php new file mode 100644 index 00000000..22a499d9 --- /dev/null +++ b/src/Analyser/NodeQueryTrait.php @@ -0,0 +1,81 @@ + $layers All layer names this node belongs to; assigned once in each node's constructor + * @property-read list $dependencies Fully-qualified class, function, or constant dependencies + * @property-read string[] $functionCalls Functions called within this node + * @property-read string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) + * @property-read string[] $languageConstructs Language constructs used (exit, die, etc.) + */ +trait NodeQueryTrait +{ + public function isInLayer(string $layer): bool + { + return in_array($layer, $this->layers, true); + } + + public function dependsOn(string $class): bool + { + return in_array($class, $this->dependencies, true); + } + + public function dependsOnNamespace(string $namespace): bool + { + $prefix = rtrim($namespace, '\\') . '\\'; + + foreach ($this->dependencies as $dependency) { + if (str_starts_with($dependency, $prefix)) { + return true; + } + } + + return false; + } + + public function callsFunction(string $function): bool + { + foreach ($this->functionCalls as $functionCall) { + if (strcasecmp($functionCall, $function) === 0) { + return true; + } + } + + return false; + } + + public function usesLanguageConstruct(string $construct): bool + { + if (in_array($construct, $this->languageConstructs, true)) { + return true; + } + + // `die` is a pure alias of `exit`, so banning either spelling catches both. + return match ($construct) { + 'exit' => in_array('die', $this->languageConstructs, true), + 'die' => in_array('exit', $this->languageConstructs, true), + default => false, + }; + } + + public function accessesSuperglobals(): bool + { + return $this->superglobals !== []; + } +} diff --git a/src/Analyser/Parallel/ClassNodeWorker.php b/src/Analyser/Parallel/AnalysisNodeWorker.php similarity index 55% rename from src/Analyser/Parallel/ClassNodeWorker.php rename to src/Analyser/Parallel/AnalysisNodeWorker.php index d2c75c8f..bfdf45ac 100644 --- a/src/Analyser/Parallel/ClassNodeWorker.php +++ b/src/Analyser/Parallel/AnalysisNodeWorker.php @@ -4,7 +4,8 @@ namespace Boundwize\StructArmed\Analyser\Parallel; -use Boundwize\StructArmed\Analyser\ClassNodeExtractor; +use Boundwize\StructArmed\Analyser\AnalysisNodeExtractor; +use Boundwize\StructArmed\Cache\AnalysisResultCache; use Boundwize\StructArmed\LayerResolver\ChainLayerResolver; use Throwable; @@ -17,7 +18,10 @@ use const STDOUT; -final readonly class ClassNodeWorker +/** + * @internal + */ +final readonly class AnalysisNodeWorker { /** @param resource|null $outputStream */ public static function run(string $inputFile, string $outputFile, mixed $outputStream = null): int @@ -53,30 +57,38 @@ public static function run(string $inputFile, string $outputFile, mixed $outputS $progressHandler = $emitProgress ? new WorkerProgressHandler($stream) : null; - $result = (new ClassNodeExtractor($layerResolver))->extract( - $files, - $progressHandler, - $withFileAnalysis, - ); + $cache = $payload['cache'] ?? null; + /** @var string $cacheNamespace */ + $cacheNamespace = $payload['cacheNamespace'] ?? ''; + + $result = (new AnalysisNodeExtractor( + $layerResolver, + analysisResultCache: $cache instanceof AnalysisResultCache ? $cache : null, + analysisNodeCacheNamespace: $cacheNamespace, + ))->extract($files, $progressHandler, $withFileAnalysis); file_put_contents($outputFile, serialize([ - 'nodes' => $result->classNodes, - 'fileAnalyses' => $result->fileAnalyses, - 'anonymousClassNodes' => $result->anonymousClassNodes, - 'fileReferences' => $result->fileReferences, - 'fileInstantiations' => $result->fileInstantiations, - 'error' => null, + 'nodes' => $result->classNodes, + 'fileAnalyses' => $result->fileAnalyses, + 'anonymousClassNodes' => $result->anonymousClassNodes, + 'fileReferences' => $result->fileReferences, + 'fileInstantiations' => $result->fileInstantiations, + 'functionNodes' => $result->functionNodes, + 'anonymousFunctionNodes' => $result->anonymousFunctionNodes, + 'error' => null, ])); return 0; } catch (Throwable $throwable) { file_put_contents($outputFile, serialize([ - 'nodes' => [], - 'fileAnalyses' => [], - 'anonymousClassNodes' => [], - 'fileReferences' => [], - 'fileInstantiations' => [], - 'error' => sprintf('%s: %s', $throwable::class, $throwable->getMessage()), + 'nodes' => [], + 'fileAnalyses' => [], + 'anonymousClassNodes' => [], + 'fileReferences' => [], + 'fileInstantiations' => [], + 'functionNodes' => [], + 'anonymousFunctionNodes' => [], + 'error' => sprintf('%s: %s', $throwable::class, $throwable->getMessage()), ])); return 1; diff --git a/src/Analyser/Parallel/ParallelClassNodeExtractor.php b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php similarity index 85% rename from src/Analyser/Parallel/ParallelClassNodeExtractor.php rename to src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php index 975bbddc..3e29e473 100644 --- a/src/Analyser/Parallel/ParallelClassNodeExtractor.php +++ b/src/Analyser/Parallel/ParallelAnalysisNodeExtractor.php @@ -5,9 +5,12 @@ namespace Boundwize\StructArmed\Analyser\Parallel; use Boundwize\StructArmed\Analyser\AnonymousClassNode; +use Boundwize\StructArmed\Analyser\AnonymousFunctionNode; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ExtractionResult; use Boundwize\StructArmed\Analyser\FileAnalysis; +use Boundwize\StructArmed\Analyser\FunctionNode; +use Boundwize\StructArmed\Cache\AnalysisResultCache; use Boundwize\StructArmed\Cache\CachePathFactory; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; use RuntimeException; @@ -42,7 +45,10 @@ use const PHP_BINARY; -final readonly class ParallelClassNodeExtractor +/** + * @internal + */ +final readonly class ParallelAnalysisNodeExtractor { /** * @param array> $layers @@ -58,6 +64,8 @@ public function __construct( private array $layerPatterns, private int $workerCount, private ?string $cacheDirectory = null, + private ?AnalysisResultCache $analysisResultCache = null, + private string $analysisNodeCacheNamespace = '', ) { } @@ -96,6 +104,8 @@ public function extract( 'files' => $chunk, 'emitProgress' => $emitProgress, 'withFileAnalysis' => $withFileAnalysis, + 'cache' => $this->analysisResultCache?->forFiles($chunk), + 'cacheNamespace' => $this->analysisNodeCacheNamespace, ])); // phpcs:disable SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFallbackGlobalName @@ -134,12 +144,14 @@ public function extract( ]; } - $nodes = []; - $fileAnalyses = []; - $anonymousClassNodes = []; - $fileReferences = []; - $fileInstantiations = []; - $failure = null; + $nodes = []; + $fileAnalyses = []; + $anonymousClassNodes = []; + $fileReferences = []; + $fileInstantiations = []; + $functionNodes = []; + $anonymousFunctionNodes = []; + $failure = null; while ($pending !== []) { $anyActivity = false; @@ -327,6 +339,38 @@ public function extract( $fileInstantiations[$file] = $validInstantiations; } + + $workerFunctionNodes = $result['functionNodes'] ?? []; + + if (! is_array($workerFunctionNodes)) { + throw new RuntimeException('Parallel analysis worker returned invalid function nodes.'); + } + + foreach ($workerFunctionNodes as $workerFunctionNode) { + if (! $workerFunctionNode instanceof FunctionNode) { + throw new RuntimeException('Parallel analysis worker returned invalid function nodes.'); + } + + $functionNodes[] = $workerFunctionNode; + } + + $workerAnonymousFunctionNodes = $result['anonymousFunctionNodes'] ?? []; + + if (! is_array($workerAnonymousFunctionNodes)) { + throw new RuntimeException( + 'Parallel analysis worker returned invalid anonymous function nodes.' + ); + } + + foreach ($workerAnonymousFunctionNodes as $workerAnonymousFunctionNode) { + if (! $workerAnonymousFunctionNode instanceof AnonymousFunctionNode) { + throw new RuntimeException( + 'Parallel analysis worker returned invalid anonymous function nodes.' + ); + } + + $anonymousFunctionNodes[] = $workerAnonymousFunctionNode; + } } catch (RuntimeException $runtimeException) { $failure ??= $runtimeException->getMessage(); } finally { @@ -350,7 +394,15 @@ public function extract( throw new RuntimeException($failure); } - return new ExtractionResult($nodes, $fileAnalyses, $anonymousClassNodes, $fileReferences, $fileInstantiations); + return new ExtractionResult( + $nodes, + $fileAnalyses, + $anonymousClassNodes, + $fileReferences, + $fileInstantiations, + $functionNodes, + $anonymousFunctionNodes, + ); } /** diff --git a/src/Analyser/RecursiveParentsTrait.php b/src/Analyser/RecursiveParentsTrait.php new file mode 100644 index 00000000..4ddab395 --- /dev/null +++ b/src/Analyser/RecursiveParentsTrait.php @@ -0,0 +1,68 @@ + $parentClasses Direct and transitive parent class names + * @property list $parentInterfaces Direct and transitive implemented or extended interface names + */ +trait RecursiveParentsTrait +{ + /** + * @param list $parentClasses + * @param list $parentInterfaces + */ + public function setRecursiveParents(array $parentClasses, array $parentInterfaces): void + { + $this->parentClasses = $parentClasses; + $this->parentInterfaces = $parentInterfaces; + } + + /** + * Implemented directly, or via any parent class or interface. + */ + public function implementsInterface(string $interface): bool + { + return $this->matchesAnyClassLike($interface, $this->implements) + || $this->matchesAnyClassLike($interface, $this->parentInterfaces); + } + + public function extendsClass(string $class): bool + { + if ($this->extends !== null && strcasecmp($this->extends, $class) === 0) { + return true; + } + + return $this->matchesAnyClassLike($class, $this->parentClasses); + } + + /** + * Class-like names are case-insensitive in PHP. This matching is kept + * separate from dependencies, which may also contain constants. + * + * @param string[] $classLikes + */ + private function matchesAnyClassLike(string $needle, array $classLikes): bool + { + foreach ($classLikes as $classLike) { + if (strcasecmp($classLike, $needle) === 0) { + return true; + } + } + + return false; + } +} diff --git a/src/Architecture.php b/src/Architecture.php index 0ac1c454..69566395 100644 --- a/src/Architecture.php +++ b/src/Architecture.php @@ -6,6 +6,9 @@ use Boundwize\StructArmed\Exception\RuleNotFoundException; use Boundwize\StructArmed\Preset\PresetInterface; +use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface; +use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; +use Boundwize\StructArmed\Rule\FunctionRuleInterface; use Boundwize\StructArmed\Rule\ProjectRuleInterface; use Boundwize\StructArmed\Rule\RuleInterface; use InvalidArgumentException; @@ -48,7 +51,10 @@ final class Architecture /** @var array> name → path prefixes */ private array $layers = []; - /** @var array key → rule */ + /** + * @var array key → rule + */ private array $rules = []; /** @var array, list|null> */ @@ -350,8 +356,11 @@ public function registerPresetSourcePaths(string $preset, ?array $sourcePaths): * Add a new custom rule. * If a rule with this key already exists it will be replaced. */ - public function rule(string $key, RuleInterface|ProjectRuleInterface $rule): self - { + public function rule( + string $key, + RuleInterface|ProjectRuleInterface|FunctionRuleInterface + |AnonymousFunctionRuleInterface|AnonymousClassRuleInterface $rule, + ): self { $this->rules[$key] = $rule; $this->resolvePendingRuleSkip($key); @@ -365,8 +374,11 @@ public function rule(string $key, RuleInterface|ProjectRuleInterface $rule): sel * * @throws RuleNotFoundException */ - public function replaceRule(string $key, RuleInterface|ProjectRuleInterface $rule): self - { + public function replaceRule( + string $key, + RuleInterface|ProjectRuleInterface|FunctionRuleInterface + |AnonymousFunctionRuleInterface|AnonymousClassRuleInterface $rule, + ): self { if (! isset($this->rules[$key])) { throw new RuleNotFoundException(sprintf( 'Cannot replace rule [%s] — rule not found. ' @@ -425,7 +437,10 @@ public function getRulesetSkipPaths(): array return $this->rulesetSkipPaths; } - /** @return array */ + /** + * @return array + */ public function getRules(): array { return $this->rules; diff --git a/src/Baseline/Baseline.php b/src/Baseline/Baseline.php index e3a0fbed..f2417b6d 100644 --- a/src/Baseline/Baseline.php +++ b/src/Baseline/Baseline.php @@ -147,7 +147,7 @@ private function isListArray(Array_ $array): bool private function prettyPrintArray(Array_ $array): string { - return (new class () extends Standard { + return (new class extends Standard { // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps protected function pExpr_Array(Array_ $node): string { diff --git a/src/Cache/AnalysisCacheMetadataFactory.php b/src/Cache/AnalysisCacheMetadataFactory.php index c60c138a..a76833c8 100644 --- a/src/Cache/AnalysisCacheMetadataFactory.php +++ b/src/Cache/AnalysisCacheMetadataFactory.php @@ -7,9 +7,11 @@ use Boundwize\StructArmed\Version; use Composer\InstalledVersions; -use function array_map; use function file_exists; use function hash; +use function hash_final; +use function hash_init; +use function hash_update; use function json_encode; use function rtrim; use function sort; @@ -36,7 +38,7 @@ public function metadata(string $basePath, string $configPath, array $scanPaths, sort($files); return [ - 'version' => 4, + 'version' => 5, 'basePath' => $basePath, 'configPath' => $configPath, 'configHash' => $this->fileHash($configPath), @@ -67,11 +69,11 @@ public function fileHash(string $path): string } /** - * Cached ClassNodes store resolved layer assignments, which depend on the + * Cached analysis nodes store resolved layer assignments, which depend on the * composer.json PSR-4 mappings as well as the config, so both hashes must * key the namespace or a composer.json change would reuse stale layers. */ - public function classNodeCacheNamespace(string $basePath, string $configHash): string + public function analysisNodeCacheNamespace(string $basePath, string $configHash): string { return hash('xxh128', $configHash . "\0" . $this->composerHash($basePath)); } @@ -81,10 +83,13 @@ public function classNodeCacheNamespace(string $basePath, string $configHash): s */ private function filesHash(array $files): string { - return hash('xxh128', json_encode(array_map(fn(string $file): array => [ - 'file' => $file, - 'hash' => $this->fileHashProvider->hash($file), - ], $files), JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR)); + $hashContext = hash_init('xxh128'); + + foreach ($files as $file) { + hash_update($hashContext, $file . "\0" . $this->fileHashProvider->hash($file) . "\0"); + } + + return hash_final($hashContext); } private function composerHash(string $basePath): string diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index 2d5d79fb..0c3c293f 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -5,19 +5,26 @@ namespace Boundwize\StructArmed\Cache; use Boundwize\StructArmed\Analyser\AnonymousClassNode; +use Boundwize\StructArmed\Analyser\AnonymousFunctionNode; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ConstantNode; use Boundwize\StructArmed\Analyser\EnumCaseNode; +use Boundwize\StructArmed\Analyser\ExtractionResult; use Boundwize\StructArmed\Analyser\FileAnalysis; +use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\Analyser\MethodNode; use Boundwize\StructArmed\Analyser\PropertyNode; +use Boundwize\StructArmed\Composer\ComposerJsonProvider; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Rule\RuleViolationCollection; +use function array_fill_keys; +use function array_is_list; use function array_key_exists; use function array_keys; use function array_map; use function array_values; +use function count; use function file_exists; use function file_get_contents; use function file_put_contents; @@ -26,12 +33,14 @@ use function is_array; use function is_bool; use function is_dir; +use function is_float; use function is_int; use function is_string; use function json_decode; use function json_encode; use function mkdir; use function rmdir; +use function rtrim; use function sprintf; use function unlink; @@ -45,24 +54,38 @@ final class AnalysisResultCache { /** - * Marker file recording the config and structarmed version hashes the cache - * contents were built with. Never collides with payload files: those are - * named by hex hash keys or a "class-nodes-" prefix. + * Marker file recording the cache format version and the config and + * structarmed version hashes the cache contents were built with. Never + * collides with payload files: those are named by hex hash keys or an + * "analysis-nodes-" prefix. */ private const METADATA_FILE = '_metadata.json'; + /** + * Format version of the analysis-node payload files. Bump it whenever + * their shape or naming changes: it is recorded in the metadata marker, + * so a cache written by an older format is cleared on its next use. + */ + public const FORMAT_VERSION = 7; + private readonly string $cacheDirectory; + /** Hash of the project composer.json: its PSR-4 mappings decide layer assignments. */ + private readonly string $composerHash; + private bool $isCacheInitialised = false; public function __construct( string $basePath, - private readonly FileHashProvider $fileHashProvider, + private FileHashProvider $fileHashProvider, ?string $cacheDirectory = null, private readonly string $configHash = '', private readonly string $composerGeneratedVersionHash = '', + private readonly ComposerJsonProvider $composerJsonProvider = new ComposerJsonProvider(), ) { $this->cacheDirectory = CachePathFactory::getPath($cacheDirectory, $basePath); + $composerFile = rtrim($basePath, '/') . '/composer.json'; + $this->composerHash = file_exists($composerFile) ? $fileHashProvider->hash($composerFile) : ''; } /** @@ -120,6 +143,7 @@ public function clear(): void { $this->isCacheInitialised = false; $this->fileHashProvider->clear(); + $this->composerJsonProvider->clear(); if (! is_dir($this->cacheDirectory)) { return; @@ -142,10 +166,22 @@ public function getCacheDirectory(): string return $this->cacheDirectory; } + /** + * @param list $files + */ + public function forFiles(array $files): self + { + $cache = clone $this; + $cache->fileHashProvider = $this->fileHashProvider->forFiles($files); + + return $cache; + } + /** * Compares against the single metadata marker instead of scanning every * payload, so the check stays O(1) regardless of cache size. A populated - * cache without a marker predates this format and must be invalidated. + * cache without a marker, or with a marker from an older cache format + * version, must be invalidated. */ public function shouldInvalidate(): bool { @@ -155,8 +191,10 @@ public function shouldInvalidate(): bool $payload = $this->readPath($this->cacheDirectory . '/' . self::METADATA_FILE); - return ($payload['configHash'] ?? null) !== $this->configHash - || ($payload['composerGeneratedVersionHash'] ?? null) !== $this->composerGeneratedVersionHash; + return ($payload['version'] ?? null) !== self::FORMAT_VERSION + || ($payload['configHash'] ?? null) !== $this->configHash + || ($payload['composerGeneratedVersionHash'] ?? null) !== $this->composerGeneratedVersionHash + || ($payload['composerHash'] ?? null) !== $this->composerHash; } private function ensureCacheInitialised(): void @@ -173,8 +211,10 @@ private function ensureCacheInitialised(): void if (! file_exists($metadataFile)) { file_put_contents($metadataFile, json_encode([ + 'version' => self::FORMAT_VERSION, 'configHash' => $this->configHash, 'composerGeneratedVersionHash' => $this->composerGeneratedVersionHash, + 'composerHash' => $this->composerHash, ], JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR)); } @@ -186,18 +226,20 @@ private function ensureCacheInitialised(): void * classNodes: list, * anonymousClassNodes: list, * fileReferences: list, - * fileInstantiations: list + * fileInstantiations: list, + * functionNodes: list, + * anonymousFunctionNodes: list * }|null */ - public function loadClassNodes(string $file, string $namespace): ?array + public function loadAnalysisNodes(string $file, string $namespace): ?array { - $payload = $this->classNodePayload($file, $namespace); + $payload = $this->analysisNodePayload($file, $namespace); if ($payload === null) { return null; } - return $this->classNodeResultFromPayload($payload); + return $this->analysisNodeResultFromPayload($payload, $file); } /** @@ -206,26 +248,28 @@ public function loadClassNodes(string $file, string $namespace): ?array * anonymousClassNodes: list, * fileReferences: list, * fileInstantiations: list, + * functionNodes: list, + * anonymousFunctionNodes: list, * fileAnalysis: FileAnalysis * }|null */ - public function loadClassNodesWithFileAnalysis(string $file, string $namespace): ?array + public function loadAnalysisNodesWithFileAnalysis(string $file, string $namespace): ?array { - $payload = $this->classNodePayload($file, $namespace); + $payload = $this->analysisNodePayload($file, $namespace); if ($payload === null) { return null; } $fileAnalysis = is_array($payload['fileAnalysis'] ?? null) - ? $this->fileAnalysisFromArray($payload['fileAnalysis']) + ? $this->fileAnalysisFromArray($payload['fileAnalysis'], $file) : null; if (! $fileAnalysis instanceof FileAnalysis) { return null; } - $result = $this->classNodeResultFromPayload($payload); + $result = $this->analysisNodeResultFromPayload($payload, $file); if ($result === null) { return null; @@ -242,37 +286,45 @@ public function loadClassNodesWithFileAnalysis(string $file, string $namespace): * classNodes: list, * anonymousClassNodes: list, * fileReferences: list, - * fileInstantiations: list + * fileInstantiations: list, + * functionNodes: list, + * anonymousFunctionNodes: list * }|null */ - private function classNodeResultFromPayload(array $payload): ?array + private function analysisNodeResultFromPayload(array $payload, string $file): ?array { - $classNodes = $this->classNodesFromPayload($payload); - $anonymousClassNodes = $this->anonymousClassNodesFromPayload($payload); - $fileReferences = $this->fileReferencesFromPayload($payload); - $fileInstantiations = $this->fileInstantiationsFromPayload($payload); + $classNodes = $this->classNodesFromPayload($payload, $file); + $anonymousClassNodes = $this->anonymousClassNodesFromPayload($payload); + $fileReferences = $this->fileReferencesFromPayload($payload); + $fileInstantiations = $this->fileInstantiationsFromPayload($payload); + $functionNodes = $this->functionNodesFromPayload($payload, $file); + $anonymousFunctionNodes = $this->anonymousFunctionNodesFromPayload($payload, $file); if ( $classNodes === null || $anonymousClassNodes === null || $fileReferences === null || $fileInstantiations === null + || $functionNodes === null + || $anonymousFunctionNodes === null ) { return null; } return [ - 'classNodes' => $classNodes, - 'anonymousClassNodes' => $anonymousClassNodes, - 'fileReferences' => $fileReferences, - 'fileInstantiations' => $fileInstantiations, + 'classNodes' => $classNodes, + 'anonymousClassNodes' => $anonymousClassNodes, + 'fileReferences' => $fileReferences, + 'fileInstantiations' => $fileInstantiations, + 'functionNodes' => $functionNodes, + 'anonymousFunctionNodes' => $anonymousFunctionNodes, ]; } /** @return array|null */ - private function classNodePayload(string $file, string $namespace): ?array + private function analysisNodePayload(string $file, string $namespace): ?array { - $payload = $this->read($this->classNodesKey($file, $namespace)); + $payload = $this->read($this->analysisNodesKey($file, $namespace)); if ($payload === null || ($payload['metadata'] ?? null) !== $this->fileMetadata($file, $namespace)) { return null; @@ -285,7 +337,7 @@ private function classNodePayload(string $file, string $namespace): ?array * @param array $payload * @return list|null */ - private function classNodesFromPayload(array $payload): ?array + private function classNodesFromPayload(array $payload, string $file): ?array { if (! is_array($payload['nodes'] ?? null)) { return null; @@ -298,7 +350,7 @@ private function classNodesFromPayload(array $payload): ?array return null; } - $classNode = $this->classNodeFromArray($node); + $classNode = $this->classNodeFromArray($node, $file); if (! $classNode instanceof ClassNode) { return null; @@ -310,14 +362,69 @@ private function classNodesFromPayload(array $payload): ?array return $nodes; } + /** + * Stores the parsed nodes of every file in $files from one extraction result, + * one payload per file (files without nodes get an empty payload too, so they + * are cache hits next run). + * + * @param list $files + */ + public function storeExtractionResult(array $files, string $namespace, ExtractionResult $extractionResult): void + { + $classNodesByFile = array_fill_keys($files, []); + $anonymousClassNodesByFile = $classNodesByFile; + $functionNodesByFile = $classNodesByFile; + $anonymousFunctionNodesByFile = $classNodesByFile; + + foreach ($extractionResult->classNodes as $classNode) { + if (isset($classNodesByFile[$classNode->file])) { + $classNodesByFile[$classNode->file][] = $classNode; + } + } + + foreach ($extractionResult->anonymousClassNodes as $anonymousClassNode) { + if (isset($anonymousClassNodesByFile[$anonymousClassNode->file])) { + $anonymousClassNodesByFile[$anonymousClassNode->file][] = $anonymousClassNode; + } + } + + foreach ($extractionResult->functionNodes as $functionNode) { + if (isset($functionNodesByFile[$functionNode->file])) { + $functionNodesByFile[$functionNode->file][] = $functionNode; + } + } + + foreach ($extractionResult->anonymousFunctionNodes as $anonymousFunctionNode) { + if (isset($anonymousFunctionNodesByFile[$anonymousFunctionNode->file])) { + $anonymousFunctionNodesByFile[$anonymousFunctionNode->file][] = $anonymousFunctionNode; + } + } + + foreach ($files as $file) { + $this->storeAnalysisNodes( + $file, + $namespace, + $classNodesByFile[$file], + $extractionResult->fileAnalyses[$file] ?? null, + $anonymousClassNodesByFile[$file], + $extractionResult->fileReferences[$file] ?? [], + $extractionResult->fileInstantiations[$file] ?? [], + $functionNodesByFile[$file], + $anonymousFunctionNodesByFile[$file], + ); + } + } + /** * @param list $classNodes * @param list $anonymousClassNodes * @param list $fileReferences Class-like references made outside any - * named class-like scope in this file + * class-like scope in this file * @param list $fileInstantiations Class-like instantiations in this file + * @param list $functionNodes + * @param list $anonymousFunctionNodes */ - public function storeClassNodes( + public function storeAnalysisNodes( string $file, string $namespace, array $classNodes, @@ -325,23 +432,37 @@ public function storeClassNodes( array $anonymousClassNodes = [], array $fileReferences = [], array $fileInstantiations = [], + array $functionNodes = [], + array $anonymousFunctionNodes = [], ): void { $this->ensureCacheInitialised(); $payload = [ - 'metadata' => $this->fileMetadata($file, $namespace), - 'nodes' => array_map($this->classNodeToArray(...), $classNodes), - 'anonymousClassNodes' => array_map($this->anonymousClassNodeToArray(...), $anonymousClassNodes), - 'fileReferences' => $fileReferences, - 'fileInstantiations' => $fileInstantiations, + 'metadata' => $this->fileMetadata($file, $namespace), + 'nodes' => array_map($this->classNodeToArray(...), $classNodes), + ]; + + // Most files have none of these; leave their keys out entirely. + $lists = [ + 'anonymousClassNodes' => array_map($this->anonymousClassNodeToArray(...), $anonymousClassNodes), + 'fileReferences' => $fileReferences, + 'fileInstantiations' => $fileInstantiations, + 'functionNodes' => array_map($this->functionNodeToArray(...), $functionNodes), + 'anonymousFunctionNodes' => array_map($this->anonymousFunctionNodeToArray(...), $anonymousFunctionNodes), ]; + foreach ($lists as $key => $list) { + if ($list !== []) { + $payload[$key] = $list; + } + } + if ($fileAnalysis instanceof FileAnalysis) { $payload['fileAnalysis'] = $this->fileAnalysisToArray($fileAnalysis); } file_put_contents( - $this->path($this->classNodesKey($file, $namespace)), + $this->path($this->analysisNodesKey($file, $namespace)), json_encode($payload, JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR) ); } @@ -373,16 +494,18 @@ private function readPath(string $path): ?array */ private function ruleViolationFromArray(array $violation): ?RuleViolation { - $ruleKey = $violation['rule'] ?? null; - $message = $violation['message'] ?? null; - $file = $violation['file'] ?? null; - $line = $violation['line'] ?? null; - $className = $violation['class'] ?? null; - $layer = $violation['layer'] ?? null; - $method = $violation['method'] ?? null; - $constant = $violation['constant'] ?? null; - $property = $violation['property'] ?? null; - $fixable = $violation['fixable'] ?? false; + $ruleKey = $violation['rule'] ?? null; + $message = $violation['message'] ?? null; + $file = $violation['file'] ?? null; + $line = $violation['line'] ?? null; + $className = $violation['class'] ?? null; + $layer = $violation['layer'] ?? null; + $method = $violation['method'] ?? null; + $constant = $violation['constant'] ?? null; + $property = $violation['property'] ?? null; + $function = $violation['function'] ?? null; + $numericLiteral = $violation['numericLiteral'] ?? null; + $fixable = $violation['fixable'] ?? false; if ( ! is_string($ruleKey) @@ -394,6 +517,8 @@ private function ruleViolationFromArray(array $violation): ?RuleViolation || ($method !== null && ! is_string($method)) || ($constant !== null && ! is_string($constant)) || ($property !== null && ! is_string($property)) + || ($function !== null && ! is_string($function)) + || ($numericLiteral !== null && ! is_string($numericLiteral)) || ! is_bool($fixable) ) { return null; @@ -410,6 +535,8 @@ className: $className, methodName: $method, constantName: $constant, propertyName: $property, + functionName: $function, + numericLiteral: $numericLiteral, ); } @@ -440,13 +567,37 @@ private function fileInstantiationsFromPayload(array $payload): ?array */ private function anonymousClassNodeToArray(AnonymousClassNode $anonymousClassNode): array { - return [ - 'file' => $anonymousClassNode->file, - 'line' => $anonymousClassNode->line, - 'extends' => $anonymousClassNode->extends, - 'implements' => $anonymousClassNode->implements, - 'traits' => $anonymousClassNode->traits, + $node = [ + 'file' => $anonymousClassNode->file, + 'line' => $anonymousClassNode->line, + 'extends' => $anonymousClassNode->extends, + 'layer' => $anonymousClassNode->layer, + 'enclosingClassName' => $anonymousClassNode->enclosingClassName, + 'enclosingFunctionName' => $anonymousClassNode->enclosingFunctionName, + 'hasEmptyParentheses' => $anonymousClassNode->hasEmptyParentheses, + 'isReadonly' => $anonymousClassNode->isReadonly, + ]; + + $lists = [ + 'implements' => array_values($anonymousClassNode->implements), + 'traits' => array_values($anonymousClassNode->traits), + 'layers' => $anonymousClassNode->layers, + 'dependencies' => $anonymousClassNode->dependencies, + 'methods' => array_map($this->methodNodeToArray(...), $anonymousClassNode->methods), + 'constants' => array_map($this->constantNodeToArray(...), $anonymousClassNode->constants), + 'properties' => array_map($this->propertyNodeToArray(...), $anonymousClassNode->properties), + 'functionCalls' => array_values($anonymousClassNode->functionCalls), + 'superglobals' => array_values($anonymousClassNode->superglobals), + 'languageConstructs' => array_values($anonymousClassNode->languageConstructs), ]; + + foreach ($lists as $key => $list) { + if ($list !== []) { + $node[$key] = $list; + } + } + + return $node; } /** @@ -468,26 +619,74 @@ private function anonymousClassNodesFromPayload(array $payload): ?array return null; } - $file = $rawNode['file'] ?? null; - $line = $rawNode['line'] ?? null; - $extends = $rawNode['extends'] ?? null; - $implements = $rawNode['implements'] ?? []; - $traits = $rawNode['traits'] ?? []; + $file = $rawNode['file'] ?? null; + $line = $rawNode['line'] ?? null; + $extends = $rawNode['extends'] ?? null; + $implements = $rawNode['implements'] ?? []; + $traits = $rawNode['traits'] ?? []; + $layer = $rawNode['layer'] ?? null; + $enclosingClassName = $rawNode['enclosingClassName'] ?? null; + $enclosingFunctionName = $rawNode['enclosingFunctionName'] ?? null; + $hasEmptyParentheses = $rawNode['hasEmptyParentheses'] ?? false; + $layers = $rawNode['layers'] ?? []; + $isReadonly = $rawNode['isReadonly'] ?? false; + $dependencies = $rawNode['dependencies'] ?? []; + $functionCalls = $rawNode['functionCalls'] ?? []; + $superglobals = $rawNode['superglobals'] ?? []; + $languageConstructs = $rawNode['languageConstructs'] ?? []; + + if ( + ! is_string($file) + || ! is_int($line) + || ($extends !== null && ! is_string($extends)) + || ($layer !== null && ! is_string($layer)) + || ($enclosingClassName !== null && ! is_string($enclosingClassName)) + || ($enclosingFunctionName !== null && ! is_string($enclosingFunctionName)) + || ! is_bool($hasEmptyParentheses) + || ! is_bool($isReadonly) + ) { + return null; + } - if (! is_string($file) || ! is_int($line) || ($extends !== null && ! is_string($extends))) { + if ( + ! $this->isStringArray($implements) + || ! $this->isStringArray($traits) + || ! $this->isStringArray($layers) + || ! $this->isStringArray($dependencies) + || ! $this->isStringArray($functionCalls) + || ! $this->isStringArray($superglobals) + || ! $this->isStringArray($languageConstructs) + ) { return null; } - if (! $this->isStringArray($implements) || ! $this->isStringArray($traits)) { + $methods = $this->memberNodesFromArray($rawNode['methods'] ?? [], $this->methodNodeFromArray(...)); + $constants = $this->memberNodesFromArray($rawNode['constants'] ?? [], $this->constantNodeFromArray(...)); + $properties = $this->memberNodesFromArray($rawNode['properties'] ?? [], $this->propertyNodeFromArray(...)); + + if ($methods === null || $constants === null || $properties === null) { return null; } $anonymousClassNodes[] = new AnonymousClassNode( - file: $file, - line: $line, - extends: $extends, - implements: $implements, - traits: $traits, + file: $file, + line: $line, + extends: $extends, + implements: $implements, + traits: $traits, + layer: $layer, + enclosingClassName: $enclosingClassName, + enclosingFunctionName: $enclosingFunctionName, + hasEmptyParentheses: $hasEmptyParentheses, + layers: array_values($layers), + isReadonly: $isReadonly, + dependencies: array_values($dependencies), + methods: $methods, + constants: $constants, + properties: $properties, + functionCalls: array_values($functionCalls), + superglobals: array_values($superglobals), + languageConstructs: array_values($languageConstructs), ); } @@ -497,20 +696,297 @@ traits: $traits, /** * @return array */ - private function classNodeToArray(ClassNode $classNode): array + private function functionNodeToArray(FunctionNode $functionNode): array + { + return ['functionName' => $functionNode->functionName] + $this->functionLikeBodyToArray( + $functionNode->line, + $functionNode->layer, + $functionNode->hasReturnType, + $functionNode->paramCount, + $functionNode->cyclomaticComplexity, + $functionNode->lineCount, + $functionNode->dependencies, + $functionNode->functionCalls, + $functionNode->superglobals, + $functionNode->languageConstructs, + $functionNode->layers, + ); + } + + /** + * The fields FunctionNode and AnonymousFunctionNode share. The file is + * not stored: the payload belongs to one file, known when loading. Empty + * lists — the common case for a closure — are left out and default on + * load, which keeps the many small function-like entries small. + * + * @param list $dependencies + * @param string[] $functionCalls + * @param string[] $superglobals + * @param string[] $languageConstructs + * @param list $layers + * @return array + */ + private function functionLikeBodyToArray( + int $line, + ?string $layer, + bool $hasReturnType, + int $paramCount, + int $cyclomaticComplexity, + int $lineCount, + array $dependencies, + array $functionCalls, + array $superglobals, + array $languageConstructs, + array $layers, + ): array { + $body = [ + 'line' => $line, + 'layer' => $layer, + 'hasReturnType' => $hasReturnType, + 'paramCount' => $paramCount, + 'cyclomaticComplexity' => $cyclomaticComplexity, + 'lineCount' => $lineCount, + ]; + + $lists = [ + 'dependencies' => $dependencies, + 'functionCalls' => $functionCalls, + 'superglobals' => $superglobals, + 'languageConstructs' => $languageConstructs, + 'layers' => $layers, + ]; + + foreach ($lists as $key => $list) { + if ($list !== []) { + $body[$key] = array_values($list); + } + } + + return $body; + } + + /** + * @param array $payload + * @return list|null + */ + private function functionNodesFromPayload(array $payload, string $file): ?array + { + $rawNodes = $payload['functionNodes'] ?? []; + + if (! is_array($rawNodes)) { + return null; + } + + $functionNodes = []; + + foreach ($rawNodes as $rawNode) { + if (! is_array($rawNode)) { + return null; + } + + $functionName = $rawNode['functionName'] ?? null; + $body = $this->functionLikeBodyFromArray($rawNode, $file); + + if (! is_string($functionName) || $body === null) { + return null; + } + + $functionNodes[] = new FunctionNode( + functionName: $functionName, + file: $body['file'], + line: $body['line'], + layer: $body['layer'], + hasReturnType: $body['hasReturnType'], + paramCount: $body['paramCount'], + cyclomaticComplexity: $body['cyclomaticComplexity'], + lineCount: $body['lineCount'], + dependencies: $body['dependencies'], + functionCalls: $body['functionCalls'], + superglobals: $body['superglobals'], + languageConstructs: $body['languageConstructs'], + layers: $body['layers'], + ); + } + + return $functionNodes; + } + + /** + * @return array + */ + private function anonymousFunctionNodeToArray(AnonymousFunctionNode $anonymousFunctionNode): array + { + return [ + 'isArrowFunction' => $anonymousFunctionNode->isArrowFunction, + 'isStatic' => $anonymousFunctionNode->isStatic, + 'enclosingClassName' => $anonymousFunctionNode->enclosingClassName, + 'enclosingFunctionName' => $anonymousFunctionNode->enclosingFunctionName, + 'usesThis' => $anonymousFunctionNode->usesThis, + ] + $this->functionLikeBodyToArray( + $anonymousFunctionNode->line, + $anonymousFunctionNode->layer, + $anonymousFunctionNode->hasReturnType, + $anonymousFunctionNode->paramCount, + $anonymousFunctionNode->cyclomaticComplexity, + $anonymousFunctionNode->lineCount, + $anonymousFunctionNode->dependencies, + $anonymousFunctionNode->functionCalls, + $anonymousFunctionNode->superglobals, + $anonymousFunctionNode->languageConstructs, + $anonymousFunctionNode->layers, + ); + } + + /** + * @param array $payload + * @return list|null + */ + private function anonymousFunctionNodesFromPayload(array $payload, string $file): ?array + { + $rawNodes = $payload['anonymousFunctionNodes'] ?? []; + + if (! is_array($rawNodes)) { + return null; + } + + $anonymousFunctionNodes = []; + + foreach ($rawNodes as $rawNode) { + if (! is_array($rawNode)) { + return null; + } + + $isArrowFunction = $rawNode['isArrowFunction'] ?? null; + $isStatic = $rawNode['isStatic'] ?? null; + $enclosingClassName = $rawNode['enclosingClassName'] ?? null; + $enclosingFunctionName = $rawNode['enclosingFunctionName'] ?? null; + $usesThis = $rawNode['usesThis'] ?? null; + $body = $this->functionLikeBodyFromArray($rawNode, $file); + + if ( + ! is_bool($isArrowFunction) + || ! is_bool($isStatic) + || ! is_bool($usesThis) + || ($enclosingClassName !== null && ! is_string($enclosingClassName)) + || ($enclosingFunctionName !== null && ! is_string($enclosingFunctionName)) + || $body === null + ) { + return null; + } + + $anonymousFunctionNodes[] = new AnonymousFunctionNode( + file: $body['file'], + line: $body['line'], + layer: $body['layer'], + isArrowFunction: $isArrowFunction, + isStatic: $isStatic, + enclosingClassName: $enclosingClassName, + enclosingFunctionName: $enclosingFunctionName, + usesThis: $usesThis, + hasReturnType: $body['hasReturnType'], + paramCount: $body['paramCount'], + cyclomaticComplexity: $body['cyclomaticComplexity'], + lineCount: $body['lineCount'], + dependencies: $body['dependencies'], + functionCalls: $body['functionCalls'], + superglobals: $body['superglobals'], + languageConstructs: $body['languageConstructs'], + layers: $body['layers'], + ); + } + + return $anonymousFunctionNodes; + } + + /** + * The fields FunctionNode and AnonymousFunctionNode share, type-checked. + * + * @param array $node + * @return array{ + * file: string, + * line: int, + * layer: string|null, + * hasReturnType: bool, + * paramCount: int, + * cyclomaticComplexity: int, + * lineCount: int, + * dependencies: list, + * functionCalls: list, + * superglobals: list, + * languageConstructs: list, + * layers: list + * }|null + */ + private function functionLikeBodyFromArray(array $node, string $file): ?array { + $line = $node['line'] ?? null; + $layer = $node['layer'] ?? null; + $hasReturnType = $node['hasReturnType'] ?? null; + $paramCount = $node['paramCount'] ?? null; + $cyclomaticComplexity = $node['cyclomaticComplexity'] ?? null; + $lineCount = $node['lineCount'] ?? null; + $dependencies = $node['dependencies'] ?? []; + $functionCalls = $node['functionCalls'] ?? []; + $superglobals = $node['superglobals'] ?? []; + $languageConstructs = $node['languageConstructs'] ?? []; + $layers = $node['layers'] ?? []; + + if ( + ! is_int($line) + || ($layer !== null && ! is_string($layer)) + || ! is_bool($hasReturnType) + || ! is_int($paramCount) + || ! is_int($cyclomaticComplexity) + || ! is_int($lineCount) + || ! $this->isStringArray($dependencies) + || ! $this->isStringArray($functionCalls) + || ! $this->isStringArray($superglobals) + || ! $this->isStringArray($languageConstructs) + || ! $this->isStringArray($layers) + ) { + return null; + } + return [ - 'className' => $classNode->className, - 'file' => $classNode->file, - 'line' => $classNode->line, - 'layer' => $classNode->layer, - 'extends' => $classNode->extends, - 'isAbstract' => $classNode->isAbstract, - 'isFinal' => $classNode->isFinal, - 'isInterface' => $classNode->isInterface, - 'isTrait' => $classNode->isTrait, - 'isEnum' => $classNode->isEnum, - 'isReadonly' => $classNode->isReadonly, + 'file' => $file, + 'line' => $line, + 'layer' => $layer, + 'hasReturnType' => $hasReturnType, + 'paramCount' => $paramCount, + 'cyclomaticComplexity' => $cyclomaticComplexity, + 'lineCount' => $lineCount, + 'dependencies' => array_values($dependencies), + 'functionCalls' => array_values($functionCalls), + 'superglobals' => array_values($superglobals), + 'languageConstructs' => array_values($languageConstructs), + 'layers' => array_values($layers), + ]; + } + + /** + * The file is not stored: the payload belongs to one file, known when + * loading. Empty lists — most of a typical class's — are left out and + * default on load. + * + * @return array + */ + private function classNodeToArray(ClassNode $classNode): array + { + $node = [ + 'className' => $classNode->className, + 'line' => $classNode->line, + 'layer' => $classNode->layer, + 'extends' => $classNode->extends, + 'isAbstract' => $classNode->isAbstract, + 'isFinal' => $classNode->isFinal, + 'isInterface' => $classNode->isInterface, + 'isTrait' => $classNode->isTrait, + 'isEnum' => $classNode->isEnum, + 'isReadonly' => $classNode->isReadonly, + 'enumBackingType' => $classNode->enumBackingType, + ]; + + $lists = [ 'dependencies' => $classNode->dependencies, 'implements' => array_values($classNode->implements), 'interfaceExtends' => array_values($classNode->interfaceExtends), @@ -521,21 +997,27 @@ private function classNodeToArray(ClassNode $classNode): array 'constants' => array_map($this->constantNodeToArray(...), $classNode->constants), 'properties' => array_map($this->propertyNodeToArray(...), $classNode->properties), 'enumCases' => array_map($this->enumCaseNodeToArray(...), $classNode->enumCases), - 'enumBackingType' => $classNode->enumBackingType, 'functionCalls' => array_values($classNode->functionCalls), 'superglobals' => array_values($classNode->superglobals), 'languageConstructs' => array_values($classNode->languageConstructs), 'layers' => $classNode->layers, ]; + + foreach ($lists as $key => $list) { + if ($list !== []) { + $node[$key] = $list; + } + } + + return $node; } /** * @param array $node */ - private function classNodeFromArray(array $node): ?ClassNode + private function classNodeFromArray(array $node, string $file): ?ClassNode { $className = $node['className'] ?? null; - $file = $node['file'] ?? null; $line = $node['line'] ?? null; $layer = $node['layer'] ?? null; $extends = $node['extends'] ?? null; @@ -545,25 +1027,20 @@ private function classNodeFromArray(array $node): ?ClassNode $isTrait = $node['isTrait'] ?? null; $isEnum = $node['isEnum'] ?? null; $isReadonly = $node['isReadonly'] ?? null; - $dependencies = $node['dependencies'] ?? null; - $implements = $node['implements'] ?? null; + $dependencies = $node['dependencies'] ?? []; + $implements = $node['implements'] ?? []; $interfaceExtends = $node['interfaceExtends'] ?? []; $parentClasses = $node['parentClasses'] ?? []; $parentInterfaces = $node['parentInterfaces'] ?? []; $traits = $node['traits'] ?? []; - $rawMethods = $node['methods'] ?? null; - $rawConstants = $node['constants'] ?? null; - $rawProperties = $node['properties'] ?? null; - $rawEnumCases = $node['enumCases'] ?? []; $enumBackingType = $node['enumBackingType'] ?? null; - $functionCalls = $node['functionCalls'] ?? null; - $superglobals = $node['superglobals'] ?? null; + $functionCalls = $node['functionCalls'] ?? []; + $superglobals = $node['superglobals'] ?? []; $languageConstructs = $node['languageConstructs'] ?? []; $layers = $node['layers'] ?? []; if ( ! is_string($className) - || ! is_string($file) || ! is_int($line) || $layer !== null && ! is_string($layer) || $extends !== null && ! is_string($extends) @@ -579,9 +1056,6 @@ private function classNodeFromArray(array $node): ?ClassNode || ! $this->isStringArray($parentClasses) || ! $this->isStringArray($parentInterfaces) || ! $this->isStringArray($traits) - || ! is_array($rawMethods) - || ! is_array($rawConstants) - || ! is_array($rawProperties) || ! $this->isStringArray($functionCalls) || ! $this->isStringArray($superglobals) || ! $this->isStringArray($languageConstructs) @@ -590,74 +1064,21 @@ private function classNodeFromArray(array $node): ?ClassNode return null; } - $methods = []; - - foreach ($rawMethods as $rawMethod) { - if (! is_array($rawMethod)) { - return null; - } - - $methodNode = $this->methodNodeFromArray($rawMethod); - - if (! $methodNode instanceof MethodNode) { - return null; - } - - $methods[] = $methodNode; - } - - $constants = []; - - foreach ($rawConstants as $rawConstant) { - if (! is_array($rawConstant)) { - return null; - } - - $constantNode = $this->constantNodeFromArray($rawConstant); - - if (! $constantNode instanceof ConstantNode) { - return null; - } - - $constants[] = $constantNode; - } - - $properties = []; + $methods = $this->memberNodesFromArray($node['methods'] ?? [], $this->methodNodeFromArray(...)); + $constants = $this->memberNodesFromArray($node['constants'] ?? [], $this->constantNodeFromArray(...)); + $properties = $this->memberNodesFromArray($node['properties'] ?? [], $this->propertyNodeFromArray(...)); + $enumCases = $this->memberNodesFromArray($node['enumCases'] ?? [], $this->enumCaseNodeFromArray(...)); - foreach ($rawProperties as $rawProperty) { - if (! is_array($rawProperty)) { - return null; - } - - $propertyNode = $this->propertyNodeFromArray($rawProperty); - - if (! $propertyNode instanceof PropertyNode) { - return null; - } - - $properties[] = $propertyNode; - } - - if (! is_array($rawEnumCases) || ($enumBackingType !== null && ! is_string($enumBackingType))) { + if ( + $methods === null + || $constants === null + || $properties === null + || $enumCases === null + || ($enumBackingType !== null && ! is_string($enumBackingType)) + ) { return null; } - $enumCases = []; - - foreach ($rawEnumCases as $rawEnumCase) { - if (! is_array($rawEnumCase)) { - return null; - } - - $enumCaseNode = $this->enumCaseNodeFromArray($rawEnumCase); - - if (! $enumCaseNode instanceof EnumCaseNode) { - return null; - } - - $enumCases[] = $enumCaseNode; - } - return new ClassNode( className: $className, file: $file, @@ -689,59 +1110,95 @@ enumBackingType: $enumBackingType, } /** - * @return array + * @template TMember of MethodNode|ConstantNode|PropertyNode|EnumCaseNode + * @param callable(array): (TMember|null) $memberFromArray + * @return list|null + */ + private function memberNodesFromArray(mixed $rawMembers, callable $memberFromArray): ?array + { + if (! is_array($rawMembers)) { + return null; + } + + $members = []; + + foreach ($rawMembers as $rawMember) { + if (! is_array($rawMember)) { + return null; + } + + $member = $memberFromArray($rawMember); + + if ($member === null) { + return null; + } + + $members[] = $member; + } + + return $members; + } + + /** + * Members are stored as positional tuples: a class has many of them, + * and their field names would otherwise be repeated for every one. + * Changing a tuple's order or length is a format change: bump + * FORMAT_VERSION, or a same-length reorder would load silently with + * the wrong values. + * + * @return array{string, string, bool, bool, int, int, int, bool, int, bool} */ private function methodNodeToArray(MethodNode $methodNode): array { return [ - 'name' => $methodNode->name, - 'visibility' => $methodNode->visibility, - 'hasReturnType' => $methodNode->hasReturnType, - 'isStatic' => $methodNode->isStatic, - 'paramCount' => $methodNode->paramCount, - 'cyclomaticComplexity' => $methodNode->cyclomaticComplexity, - 'lineCount' => $methodNode->lineCount, - 'hasExplicitVisibility' => $methodNode->hasExplicitVisibility, - 'line' => $methodNode->line, - 'isMagic' => $methodNode->isMagic, + $methodNode->name, + $methodNode->visibility, + $methodNode->hasReturnType, + $methodNode->isStatic, + $methodNode->paramCount, + $methodNode->cyclomaticComplexity, + $methodNode->lineCount, + $methodNode->hasExplicitVisibility, + $methodNode->line, + $methodNode->isMagic, ]; } /** - * @return array + * @return array{string, string, bool, int} */ private function constantNodeToArray(ConstantNode $constantNode): array { return [ - 'name' => $constantNode->name, - 'visibility' => $constantNode->visibility, - 'hasExplicitVisibility' => $constantNode->hasExplicitVisibility, - 'line' => $constantNode->line, + $constantNode->name, + $constantNode->visibility, + $constantNode->hasExplicitVisibility, + $constantNode->line, ]; } /** - * @return array + * @return array{string, string, bool, int} */ private function propertyNodeToArray(PropertyNode $propertyNode): array { return [ - 'name' => $propertyNode->name, - 'visibility' => $propertyNode->visibility, - 'hasExplicitVisibility' => $propertyNode->hasExplicitVisibility, - 'line' => $propertyNode->line, + $propertyNode->name, + $propertyNode->visibility, + $propertyNode->hasExplicitVisibility, + $propertyNode->line, ]; } /** - * @return array + * @return array{string, int, int|string|null} */ private function enumCaseNodeToArray(EnumCaseNode $enumCaseNode): array { return [ - 'name' => $enumCaseNode->name, - 'line' => $enumCaseNode->line, - 'value' => $enumCaseNode->value, + $enumCaseNode->name, + $enumCaseNode->line, + $enumCaseNode->value, ]; } @@ -750,32 +1207,49 @@ private function enumCaseNodeToArray(EnumCaseNode $enumCaseNode): array */ private function methodNodeFromArray(array $method): ?MethodNode { + if (count($method) !== 10 || ! array_is_list($method)) { + return null; + } + + [ + $name, + $visibility, + $hasReturnType, + $isStatic, + $paramCount, + $cyclomaticComplexity, + $lineCount, + $hasExplicitVisibility, + $line, + $isMagic, + ] = $method; + if ( - ! is_string($method['name'] ?? null) - || ! is_string($method['visibility'] ?? null) - || ! is_bool($method['hasReturnType'] ?? null) - || ! is_bool($method['isStatic'] ?? null) - || ! is_int($method['paramCount'] ?? null) - || ! is_int($method['cyclomaticComplexity'] ?? null) - || ! is_int($method['lineCount'] ?? null) - || ! is_bool($method['hasExplicitVisibility'] ?? null) - || ! is_int($method['line'] ?? null) - || ! is_bool($method['isMagic'] ?? null) + ! is_string($name) + || ! is_string($visibility) + || ! is_bool($hasReturnType) + || ! is_bool($isStatic) + || ! is_int($paramCount) + || ! is_int($cyclomaticComplexity) + || ! is_int($lineCount) + || ! is_bool($hasExplicitVisibility) + || ! is_int($line) + || ! is_bool($isMagic) ) { return null; } return new MethodNode( - name: $method['name'], - visibility: $method['visibility'], - hasReturnType: $method['hasReturnType'], - isStatic: $method['isStatic'], - paramCount: $method['paramCount'], - cyclomaticComplexity: $method['cyclomaticComplexity'], - lineCount: $method['lineCount'], - hasExplicitVisibility: $method['hasExplicitVisibility'], - line: $method['line'], - isMagic: $method['isMagic'], + name: $name, + visibility: $visibility, + hasReturnType: $hasReturnType, + isStatic: $isStatic, + paramCount: $paramCount, + cyclomaticComplexity: $cyclomaticComplexity, + lineCount: $lineCount, + hasExplicitVisibility: $hasExplicitVisibility, + line: $line, + isMagic: $isMagic, ); } @@ -784,20 +1258,21 @@ private function methodNodeFromArray(array $method): ?MethodNode */ private function constantNodeFromArray(array $constant): ?ConstantNode { - if ( - ! is_string($constant['name'] ?? null) - || ! is_string($constant['visibility'] ?? null) - || ! is_bool($constant['hasExplicitVisibility'] ?? null) - || ! is_int($constant['line'] ?? null) - ) { + if (count($constant) !== 4 || ! array_is_list($constant)) { + return null; + } + + [$name, $visibility, $hasExplicitVisibility, $line] = $constant; + + if (! is_string($name) || ! is_string($visibility) || ! is_bool($hasExplicitVisibility) || ! is_int($line)) { return null; } return new ConstantNode( - name: $constant['name'], - visibility: $constant['visibility'], - hasExplicitVisibility: $constant['hasExplicitVisibility'], - line: $constant['line'], + name: $name, + visibility: $visibility, + hasExplicitVisibility: $hasExplicitVisibility, + line: $line, ); } @@ -806,20 +1281,21 @@ private function constantNodeFromArray(array $constant): ?ConstantNode */ private function propertyNodeFromArray(array $property): ?PropertyNode { - if ( - ! is_string($property['name'] ?? null) - || ! is_string($property['visibility'] ?? null) - || ! is_bool($property['hasExplicitVisibility'] ?? null) - || ! is_int($property['line'] ?? null) - ) { + if (count($property) !== 4 || ! array_is_list($property)) { + return null; + } + + [$name, $visibility, $hasExplicitVisibility, $line] = $property; + + if (! is_string($name) || ! is_string($visibility) || ! is_bool($hasExplicitVisibility) || ! is_int($line)) { return null; } return new PropertyNode( - name: $property['name'], - visibility: $property['visibility'], - hasExplicitVisibility: $property['hasExplicitVisibility'], - line: $property['line'], + name: $name, + visibility: $visibility, + hasExplicitVisibility: $hasExplicitVisibility, + line: $line, ); } @@ -828,28 +1304,32 @@ private function propertyNodeFromArray(array $property): ?PropertyNode */ private function enumCaseNodeFromArray(array $enumCase): ?EnumCaseNode { - $value = $enumCase['value'] ?? null; + if (count($enumCase) !== 3 || ! array_is_list($enumCase)) { + return null; + } - if ( - ! is_string($enumCase['name'] ?? null) - || ! is_int($enumCase['line'] ?? null) - || ($value !== null && ! is_int($value) && ! is_string($value)) - ) { + [$name, $line, $value] = $enumCase; + + if (! is_string($name) || ! is_int($line) || ($value !== null && ! is_int($value) && ! is_string($value))) { return null; } return new EnumCaseNode( - name: $enumCase['name'], - line: $enumCase['line'], + name: $name, + line: $line, value: $value, ); } - /** @return array */ + /** + * The file is not stored: the payload belongs to one file, known when + * loading. The two lists are empty for most files and left out. + * + * @return array + */ private function fileAnalysisToArray(FileAnalysis $fileAnalysis): array { - return [ - 'file' => $fileAnalysis->file, + $analysis = [ 'hasUtf8Bom' => $fileAnalysis->hasUtf8Bom, 'hasValidUtf8' => $fileAnalysis->hasValidUtf8, 'invalidPhpTagLine' => $fileAnalysis->invalidPhpTagLine, @@ -858,14 +1338,26 @@ private function fileAnalysisToArray(FileAnalysis $fileAnalysis): array 'hasSideEffects' => $fileAnalysis->hasSideEffects, 'sideEffectLine' => $fileAnalysis->sideEffectLine, ]; + + if ($fileAnalysis->nonCanonicalKeywordConstants !== []) { + $analysis['nonCanonicalKeywordConstants'] = $fileAnalysis->nonCanonicalKeywordConstants; + } + + if ($fileAnalysis->numericLiterals !== []) { + $analysis['numericLiterals'] = $fileAnalysis->numericLiterals; + } + + return $analysis; } /** @param array $analysis */ - private function fileAnalysisFromArray(array $analysis): ?FileAnalysis + private function fileAnalysisFromArray(array $analysis, string $file): ?FileAnalysis { + $nonCanonicalKeywordConstants = $analysis['nonCanonicalKeywordConstants'] ?? []; + $numericLiterals = $analysis['numericLiterals'] ?? []; + if ( - ! is_string($analysis['file'] ?? null) - || ! is_bool($analysis['hasUtf8Bom'] ?? null) + ! is_bool($analysis['hasUtf8Bom'] ?? null) || ! is_bool($analysis['hasValidUtf8'] ?? null) || ! array_key_exists('invalidPhpTagLine', $analysis) || ($analysis['invalidPhpTagLine'] !== null && ! is_int($analysis['invalidPhpTagLine'])) @@ -873,12 +1365,14 @@ private function fileAnalysisFromArray(array $analysis): ?FileAnalysis || ! is_bool($analysis['declaresSymbols'] ?? null) || ! is_bool($analysis['hasSideEffects'] ?? null) || ! is_int($analysis['sideEffectLine'] ?? null) + || ! $this->isKeywordConstantList($nonCanonicalKeywordConstants) + || ! $this->isNumericLiteralList($numericLiterals) ) { return null; } return new FileAnalysis( - file: $analysis['file'], + file: $file, hasUtf8Bom: $analysis['hasUtf8Bom'], hasValidUtf8: $analysis['hasValidUtf8'], invalidPhpTagLine: $analysis['invalidPhpTagLine'], @@ -886,9 +1380,58 @@ private function fileAnalysisFromArray(array $analysis): ?FileAnalysis declaresSymbols: $analysis['declaresSymbols'], hasSideEffects: $analysis['hasSideEffects'], sideEffectLine: $analysis['sideEffectLine'], + nonCanonicalKeywordConstants: $nonCanonicalKeywordConstants, + numericLiterals: $numericLiterals, ); } + /** + * @phpstan-assert-if-true list $value + */ + private function isKeywordConstantList(mixed $value): bool + { + if (! is_array($value) || ! array_is_list($value)) { + return false; + } + + foreach ($value as $keywordConstant) { + if ( + ! is_array($keywordConstant) + || count($keywordConstant) !== 2 + || ! is_int($keywordConstant[0] ?? null) + || ! is_string($keywordConstant[1] ?? null) + ) { + return false; + } + } + + return true; + } + + /** + * @phpstan-assert-if-true list $value + */ + private function isNumericLiteralList(mixed $value): bool + { + if (! is_array($value) || ! array_is_list($value)) { + return false; + } + + foreach ($value as $numericLiteral) { + if ( + ! is_array($numericLiteral) + || count($numericLiteral) !== 3 + || ! is_int($numericLiteral[0] ?? null) + || ! is_string($numericLiteral[1] ?? null) + || (! is_int($numericLiteral[2] ?? null) && ! is_float($numericLiteral[2] ?? null)) + ) { + return false; + } + } + + return true; + } + /** * @param array $array * @phpstan-assert-if-true array $array @@ -927,9 +1470,9 @@ private function path(string $key): string return sprintf('%s/%s.json', $this->cacheDirectory, $key); } - private function classNodesKey(string $file, string $namespace): string + private function analysisNodesKey(string $file, string $namespace): string { - return 'class-nodes-' . hash('xxh128', $namespace . "\0" . $file); + return 'analysis-nodes-' . hash('xxh128', $namespace . "\0" . $file); } /** diff --git a/src/Cache/FileHashProvider.php b/src/Cache/FileHashProvider.php index 52d3aa82..7a9b0802 100644 --- a/src/Cache/FileHashProvider.php +++ b/src/Cache/FileHashProvider.php @@ -4,6 +4,8 @@ namespace Boundwize\StructArmed\Cache; +use function array_fill_keys; +use function array_intersect_key; use function hash_file; /** @@ -30,6 +32,17 @@ public function hash(string $file): string return $this->hashes[$file] = $hash; } + /** + * @param list $files + */ + public function forFiles(array $files): self + { + $provider = new self(); + $provider->hashes = array_intersect_key($this->hashes, array_fill_keys($files, true)); + + return $provider; + } + public function clear(): void { $this->hashes = []; diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 51806e7f..89df37bc 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -18,15 +18,19 @@ use Boundwize\StructArmed\Report\Reports\ConsoleReport; use Boundwize\StructArmed\Report\Reports\JsonReport; use Boundwize\StructArmed\Rule\FixableInterface; +use Boundwize\StructArmed\Rule\Fixer\JsonRecast\AbstractJsonRecastFixableRule; +use Boundwize\StructArmed\Rule\Fixer\PhpParser\AbstractPhpParserFixableRule; use Boundwize\StructArmed\Rule\RuleViolationCollection; use Boundwize\StructArmed\Util\Path; use RuntimeException; +use function array_shift; use function count; use function explode; use function in_array; use function is_dir; use function is_file; +use function max; use function microtime; use function sprintf; use function str_starts_with; @@ -136,8 +140,11 @@ public function run(array $arguments, string $basePath): int $configHash, $composerGeneratedVersionHash ); - $classNodeCacheNamespace = $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, $configHash); - $analyser = new Analyser($basePath, $analysisResultCache, $classNodeCacheNamespace); + $analysisNodeCacheNamespace = $analysisCacheMetadataFactory->analysisNodeCacheNamespace( + $basePath, + $configHash + ); + $analyser = new Analyser($basePath, $analysisResultCache, $analysisNodeCacheNamespace); if (isset($options['clear-cache']) || $analysisResultCache->shouldInvalidate()) { $analysisResultCache->clear(); @@ -180,7 +187,7 @@ public function run(array $arguments, string $basePath): int return $this->reportError($runtimeException); } - if (isset($options['fix'])) { + if (isset($options['fix']) && ! $ruleViolationCollection->isEmpty()) { // Removal fixers can cascade: deleting an unused child abstraction // may leave its parent unused, so fix and re-analyse until a pass // fixes nothing. The pass cap only guards against a fixer that @@ -192,7 +199,7 @@ public function run(array $arguments, string $basePath): int break; } - $fixedCount += $passFixedCount; + $violationCountBeforePass = $ruleViolationCollection->count(); $analysisResultCache->clear(); $files = $analyser->filesForAnalysis($architecture, $scanPaths); @@ -223,7 +230,19 @@ public function run(array $arguments, string $basePath): int return $this->reportError($runtimeException); } - $elapsed = microtime(true) - $start; + // One fix can resolve several violations at once (e.g. two + // closures starting on the same line), and the later ones + // then report nothing to fix. Count what the re-analysis shows + // resolved, never less than the fixes that reported success. + $fixedCount += max( + $passFixedCount, + $violationCountBeforePass - $ruleViolationCollection->count() + ); + $elapsed = microtime(true) - $start; + + if ($ruleViolationCollection->isEmpty()) { + break; + } } } @@ -316,14 +335,41 @@ private function resolveRuleViolationCollection( private function fixViolations(Architecture $architecture, RuleViolationCollection $ruleViolationCollection): int { - $rules = $architecture->getRules(); $fixedCount = 0; - foreach ($ruleViolationCollection as $ruleViolation) { - $rule = $rules[$ruleViolation->ruleKey] ?? null; + foreach ($architecture->getRules() as $ruleKey => $rule) { + $ruleViolations = $ruleViolationCollection->forRule($ruleKey); - if ($rule instanceof FixableInterface && $rule->fix($ruleViolation)) { - $fixedCount++; + if (! $rule instanceof FixableInterface || $ruleViolations === []) { + continue; + } + + if ( + ! $rule instanceof AbstractPhpParserFixableRule + && ! $rule instanceof AbstractJsonRecastFixableRule + ) { + foreach ($ruleViolations as $ruleViolation) { + if ($rule->fix($ruleViolation)) { + $fixedCount++; + } + } + + continue; + } + + $violationsByFile = []; + + foreach ($ruleViolations as $ruleViolation) { + $violationsByFile[$ruleViolation->file][] = $ruleViolation; + } + + foreach ($violationsByFile as $violations) { + $batchSize = count($violations); + $firstViolation = array_shift($violations); + + if ($rule->fix($firstViolation, ...$violations)) { + $fixedCount += $batchSize; + } } } diff --git a/src/Cli/InitCommand.php b/src/Cli/InitCommand.php index be1ae3c7..ea3b27da 100644 --- a/src/Cli/InitCommand.php +++ b/src/Cli/InitCommand.php @@ -83,19 +83,23 @@ private function presetConfig(string $preset): ?string return match ($preset) { 'ddd' => ' ->withPreset(Preset::DDD());', 'mvc' => ' ->withPreset(Preset::MVC());', + 'psr4' => ' ->withPreset(Preset::PSR4());', 'psr1' => ' ->withPreset(Preset::PSR1());', 'psr12' => ' ->withPreset(Preset::PSR12());', + 'per' => ' ->withPreset(Preset::PER());', 'psr15' => ' ->withPreset(Preset::PSR15());', - 'psr4' => ' ->withPreset(Preset::PSR4());', 'yagni' => ' ->withPreset(Preset::YAGNI());', + 'codequality' => ' ->withPreset(Preset::CODEQUALITY());', 'all' => " ->withPresets(\n" + . " Preset::PSR4(),\n" . " Preset::PSR1(),\n" . " Preset::PSR12(),\n" + . " Preset::PER(),\n" . " Preset::PSR15(),\n" - . " Preset::PSR4(),\n" . " Preset::DDD(),\n" . " Preset::MVC(),\n" - . " Preset::YAGNI()\n" + . " Preset::YAGNI(),\n" + . " Preset::CODEQUALITY()\n" . " );", default => null, }; diff --git a/src/Cli/StructArmedApplication.php b/src/Cli/StructArmedApplication.php index 600d5505..90660ea6 100644 --- a/src/Cli/StructArmedApplication.php +++ b/src/Cli/StructArmedApplication.php @@ -4,7 +4,7 @@ namespace Boundwize\StructArmed\Cli; -use Boundwize\StructArmed\Analyser\Parallel\ClassNodeWorker; +use Boundwize\StructArmed\Analyser\Parallel\AnalysisNodeWorker; use Boundwize\StructArmed\Version; use function array_slice; @@ -23,7 +23,7 @@ public function run(array $argv, ?string $basePath = null): int $command = $argv[1] ?? null; if ($command === '--internal-worker') { - return ClassNodeWorker::run($argv[2] ?? '', $argv[3] ?? ''); + return AnalysisNodeWorker::run($argv[2] ?? '', $argv[3] ?? ''); } if (in_array($command, ['--version', '-V'], true)) { diff --git a/src/Cli/Usage.php b/src/Cli/Usage.php index 1752c038..4e871447 100644 --- a/src/Cli/Usage.php +++ b/src/Cli/Usage.php @@ -11,7 +11,7 @@ public static function render(): string return <<<'TXT' Usage: structarmed --version - structarmed init [--preset=ddd|mvc|psr1|psr12|psr15|psr4|yagni|all] + structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|codequality|all] structarmed analyse|analyze [path ...] [--config=path/to/structarmed.php] [--report=console|json] [--no-progress] [--clear-cache] [--disable-parallel] [--fix] [--generate-baseline=structarmed-baseline.php] diff --git a/src/Composer/ComposerJsonProvider.php b/src/Composer/ComposerJsonProvider.php new file mode 100644 index 00000000..53404bfe --- /dev/null +++ b/src/Composer/ComposerJsonProvider.php @@ -0,0 +1,69 @@ +|null> */ + private static array $decodedByFile = []; + + /** + * @return array|null + */ + public function config(string $basePath): ?array + { + $composerFile = Path::normalise(Path::resolve('composer.json', $basePath), canonicalise: true); + + if (array_key_exists($composerFile, self::$decodedByFile)) { + return self::$decodedByFile[$composerFile]; + } + + return self::$decodedByFile[$composerFile] = file_exists($composerFile) + ? $this->decode((string) file_get_contents($composerFile)) + : null; + } + + public function clear(): void + { + self::$decodedByFile = []; + } + + /** + * @return array|null + */ + private function decode(string $contents): ?array + { + $composer = json_decode($contents, true); + + if (! is_array($composer)) { + return null; + } + + foreach ($composer as $key => $value) { + if (is_int($key)) { + return null; + } + } + + return $composer; + } +} diff --git a/src/Composer/Psr4PathResolver.php b/src/Composer/Psr4PathResolver.php index 43f43e99..1fdc09a1 100644 --- a/src/Composer/Psr4PathResolver.php +++ b/src/Composer/Psr4PathResolver.php @@ -8,17 +8,17 @@ use function array_merge; use function array_values; -use function file_exists; -use function file_get_contents; use function is_array; -use function is_int; use function is_string; -use function json_decode; -use function rtrim; use function trim; -final class Psr4PathResolver +final readonly class Psr4PathResolver { + public function __construct( + private ComposerJsonProvider $composerJsonProvider = new ComposerJsonProvider(), + ) { + } + /** * @return list */ @@ -72,29 +72,7 @@ public function namespacePaths(string $basePath): array */ public function composerConfig(string $basePath): ?array { - $composerFile = rtrim($basePath, '/') . '/composer.json'; - - if (! file_exists($composerFile)) { - return null; - } - - $composer = json_decode((string) file_get_contents($composerFile), true); - - if (! is_array($composer)) { - return null; - } - - $config = []; - - foreach ($composer as $key => $value) { - if (is_int($key)) { - return null; - } - - $config[$key] = $value; - } - - return $config; + return $this->composerJsonProvider->config($basePath); } /** diff --git a/src/LayerResolver/Resolvers/NamespaceLayerResolver.php b/src/LayerResolver/Resolvers/NamespaceLayerResolver.php index 6036e152..4ec15878 100644 --- a/src/LayerResolver/Resolvers/NamespaceLayerResolver.php +++ b/src/LayerResolver/Resolvers/NamespaceLayerResolver.php @@ -19,7 +19,13 @@ */ final readonly class NamespaceLayerResolver implements LayerResolverInterface { - /** @var array> */ + /** + * Layer paths stored with a trailing '/' so a single str_starts_with() + * against the file path (also suffixed with '/') covers both exact and + * descendant matches. + * + * @var array> + */ private array $normalisedLayers; /** @@ -36,7 +42,7 @@ public function __construct( $normalisedLayers[$layerName][] = Path::normalise( Path::resolve($layerPath, $basePath), canonicalise: true - ); + ) . '/'; } } @@ -45,13 +51,13 @@ public function __construct( public function resolve(string $className, string $filePath): ?string { - $normalised = Path::normalise($filePath, canonicalise: true); + $pathWithSlash = Path::normalise($filePath, canonicalise: true) . '/'; $matchedLayer = null; $matchedLength = -1; foreach ($this->normalisedLayers as $layerName => $layerPaths) { foreach ($layerPaths as $layerPath) { - if ($this->matchesLayerPath($normalised, $layerPath)) { + if (str_starts_with($pathWithSlash, $layerPath)) { $length = strlen($layerPath); if ($length > $matchedLength) { @@ -70,12 +76,12 @@ public function resolve(string $className, string $filePath): ?string */ public function resolveAll(string $className, string $filePath): array { - $normalised = Path::normalise($filePath, canonicalise: true); - $matched = []; + $pathWithSlash = Path::normalise($filePath, canonicalise: true) . '/'; + $matched = []; foreach ($this->normalisedLayers as $layerName => $layerPaths) { foreach ($layerPaths as $layerPath) { - if ($this->matchesLayerPath($normalised, $layerPath)) { + if (str_starts_with($pathWithSlash, $layerPath)) { $matched[] = $layerName; break; } @@ -84,9 +90,4 @@ public function resolveAll(string $className, string $filePath): array return $matched; } - - private function matchesLayerPath(string $path, string $layerPath): bool - { - return $path === $layerPath || str_starts_with($path, $layerPath . '/'); - } } diff --git a/src/PHPUnit/StructArmedExtension.php b/src/PHPUnit/StructArmedExtension.php index e3a71802..eb1a43f3 100644 --- a/src/PHPUnit/StructArmedExtension.php +++ b/src/PHPUnit/StructArmedExtension.php @@ -62,7 +62,7 @@ public function bootstrap( $analyser = new Analyser( $basePath, $analysisResultCache, - $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, $configHash) + $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, $configHash) ); $files = $analyser->filesForAnalysis($architecture); diff --git a/src/Preset/Preset.php b/src/Preset/Preset.php index 5124f601..e810a360 100644 --- a/src/Preset/Preset.php +++ b/src/Preset/Preset.php @@ -4,8 +4,10 @@ namespace Boundwize\StructArmed\Preset; +use Boundwize\StructArmed\Preset\Presets\CodeQualityPreset; use Boundwize\StructArmed\Preset\Presets\DddPreset; use Boundwize\StructArmed\Preset\Presets\MvcPreset; +use Boundwize\StructArmed\Preset\Presets\PerPreset; use Boundwize\StructArmed\Preset\Presets\Psr12Preset; use Boundwize\StructArmed\Preset\Presets\Psr15Preset; use Boundwize\StructArmed\Preset\Presets\Psr1Preset; @@ -18,11 +20,13 @@ * Usage: * ->withPreset(Preset::DDD()) * ->withPreset(Preset::DDD(maxComplexity: 3)) - * ->withPreset(Preset::PSR1()) * ->withPreset(Preset::PSR4()) + * ->withPreset(Preset::PSR1()) * ->withPreset(Preset::PSR12()) + * ->withPreset(Preset::PER()) * ->withPreset(Preset::PSR15()) * ->withPreset(Preset::YAGNI()) + * ->withPreset(Preset::CODEQUALITY()) * ->withPresets(Preset::DDD(), Preset::MVC()) */ final class Preset @@ -60,6 +64,17 @@ public static function PSR12( ); } + /** + * @param list|null $sourcePaths + */ + public static function PER( + ?array $sourcePaths = null, + ): PerPreset { + return new PerPreset( + sourcePaths: $sourcePaths, + ); + } + /** * @param list|null $sourcePaths */ @@ -98,6 +113,17 @@ public static function YAGNI( ); } + /** + * @param list|null $sourcePaths + */ + public static function CODEQUALITY( + ?array $sourcePaths = null, + ): CodeQualityPreset { + return new CodeQualityPreset( + sourcePaths: $sourcePaths, + ); + } + public static function MVC( int $controllerMaxComplexity = 5, int $controllerMaxMethodLength = 20, diff --git a/src/Preset/Presets/CodeQualityPreset.php b/src/Preset/Presets/CodeQualityPreset.php new file mode 100644 index 00000000..c4c96f8d --- /dev/null +++ b/src/Preset/Presets/CodeQualityPreset.php @@ -0,0 +1,47 @@ +|null $sourcePaths + */ + public function __construct( + private ?array $sourcePaths = null, + ) { + } + + public function apply(Architecture $architecture): void + { + $layerName = $this->resolveLayerName($architecture); + $architecture->layer($layerName, $this->sourcePaths ?? []); + + $architecture->rule( + self::ANONYMOUS_FUNCTIONS_MUST_BE_STATIC, + new MustBeStaticAnonymousFunctionRule($layerName) + ); + $architecture->rule( + self::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR, + new LargeNumericLiteralMustUseSeparatorRule(sourcePaths: $this->sourcePaths) + ); + } +} diff --git a/src/Preset/Presets/DddPreset.php b/src/Preset/Presets/DddPreset.php index 14dd90a6..2ba49ded 100644 --- a/src/Preset/Presets/DddPreset.php +++ b/src/Preset/Presets/DddPreset.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Preset\PresetInterface; +use Boundwize\StructArmed\Rule\Rules\Class_\MayNotExtendClassRule; use Boundwize\StructArmed\Rule\Rules\Class_\MayNotImplementInterfaceRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeInterfaceRule; @@ -52,6 +53,9 @@ public const REPOSITORY_IMPL_IN_INFRASTRUCTURE = 'ddd.repository.implementation_in_infrastructure'; + public const DOMAIN_MUST_NOT_EXTEND_DOCTRINE_ENTITY_REPOSITORY = + 'ddd.repository.domain_must_not_extend_doctrine_entity_repository'; + // Service rules public const DOMAIN_SERVICE_IN_DOMAIN = 'ddd.service.domain_service_in_domain'; @@ -173,6 +177,14 @@ classNamePattern: '/ValueObject$/' private function applyRepositoryRules(Architecture $architecture): self { + $architecture->rule( + self::DOMAIN_MUST_NOT_EXTEND_DOCTRINE_ENTITY_REPOSITORY, + new MayNotExtendClassRule( + layer: 'Domain', + class: 'Doctrine\\ORM\\EntityRepository' + ) + ); + $architecture->rule( self::REPOSITORY_MUST_BE_INTERFACE, new MustBeInterfaceRule(layer: 'Domain', classNamePattern: '/Repository$/') diff --git a/src/Preset/Presets/MvcPreset.php b/src/Preset/Presets/MvcPreset.php index f70e7641..91f990b9 100644 --- a/src/Preset/Presets/MvcPreset.php +++ b/src/Preset/Presets/MvcPreset.php @@ -10,6 +10,7 @@ use Boundwize\StructArmed\Rule\Rules\Class_\ClassNameMustNotHavePrefixRule; use Boundwize\StructArmed\Rule\Rules\Class_\MaxDependencyCountRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; +use Boundwize\StructArmed\Rule\Rules\Function_\MustHaveReturnTypeFunctionRule; use Boundwize\StructArmed\Rule\Rules\Layer\MayNotDependOnRule; use Boundwize\StructArmed\Rule\Rules\Method\MaxCyclomaticComplexityRule; use Boundwize\StructArmed\Rule\Rules\Method\MaxMethodLengthRule; @@ -96,6 +97,9 @@ public const SERVICE_MUST_HAVE_RETURN_TYPES = 'mvc.service.must_have_return_types'; + // Helper rules + public const HELPER_MUST_HAVE_RETURN_TYPES = 'mvc.helper.must_have_return_types'; + public function __construct( private int $controllerMaxComplexity = 5, private int $controllerMaxMethodLength = 20, @@ -114,6 +118,7 @@ public function apply(Architecture $architecture): void ->applyModelRules($architecture) ->applyViewRules($architecture) ->applyServiceRules($architecture) + ->applyHelperRules($architecture) ->applySafetyRules($architecture); } @@ -134,6 +139,11 @@ private function applyDefaultLayers(Architecture $architecture): self ], 'View' => 'src/View/', 'Service' => 'src/Service/', + 'Helper' => [ + 'src/Helper/', + 'src/Helpers/', + 'app/Helpers/', + ], ]; foreach ($defaultLayers as $layer => $path) { @@ -156,6 +166,7 @@ private function applyDefaultLayerPatterns(Architecture $architecture): self 'Model' => '/(?:^|\\\\)Models?(?:\\\\|$)/', 'View' => '/(?:^|\\\\)Views?(?:\\\\|$)/', 'Service' => '/(?:^|\\\\)Services?(?:\\\\|$)/', + 'Helper' => '/(?:^|\\\\)Helpers?(?:\\\\|$)/', ]; $testNamespaceOrClassPattern = '/(?:^|\\\\)[^\\\\]*Tests?(?:\\\\|$)/'; @@ -357,6 +368,16 @@ private function applyServiceRules(Architecture $architecture): self return $this; } + private function applyHelperRules(Architecture $architecture): self + { + $architecture->rule( + self::HELPER_MUST_HAVE_RETURN_TYPES, + new MustHaveReturnTypeFunctionRule(layer: 'Helper') + ); + + return $this; + } + private function applySafetyRules(Architecture $architecture): self { foreach (['Controller', 'Model', 'View', 'Service'] as $layer) { diff --git a/src/Preset/Presets/PerPreset.php b/src/Preset/Presets/PerPreset.php new file mode 100644 index 00000000..dfb3344a --- /dev/null +++ b/src/Preset/Presets/PerPreset.php @@ -0,0 +1,74 @@ +|null $sourcePaths + */ + public function __construct( + private ?array $sourcePaths = null, + ) { + } + + public function apply(Architecture $architecture): void + { + $sourcePathsForPer = $architecture->registerPresetSourcePaths(self::class, $this->sourcePaths); + $sourcePathsForPsr12 = $architecture->registerPresetSourcePaths(Psr12Preset::class, $this->sourcePaths); + + $psr12Preset = new Psr12Preset($sourcePathsForPsr12); + $psr12Preset->apply($architecture); + + // PER rules use only paths accumulated for PER. Paths registered + // exclusively for PSR-1, PSR-4, or PSR-12 must not broaden PER + // enforcement. + $layerName = $this->resolveLayerName($architecture, $sourcePathsForPer); + $architecture->layer($layerName, $sourcePathsForPer ?? []); + + $architecture->rule( + self::ENUM_CASES_MUST_BE_PASCAL_CASE, + new EnumCaseNameMustBePascalCaseRule($layerName) + ); + + $architecture->rule( + self::ENUM_METHODS_MAY_NOT_BE_PROTECTED, + new EnumMethodMayNotBeProtectedRule($layerName) + ); + + $architecture->rule( + self::ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED, + new EnumConstantMayNotBeProtectedRule($layerName) + ); + + $architecture->rule( + self::ANONYMOUS_CLASSES_MAY_NOT_HAVE_EMPTY_PARENTHESES, + new AnonymousClassMayNotHaveEmptyParenthesesRule($layerName) + ); + } +} diff --git a/src/Preset/Presets/Psr12Preset.php b/src/Preset/Presets/Psr12Preset.php index 95a3ae54..4fa0d251 100644 --- a/src/Preset/Presets/Psr12Preset.php +++ b/src/Preset/Presets/Psr12Preset.php @@ -9,6 +9,7 @@ use Boundwize\StructArmed\Rule\Rules\Class_\MustDeclareConstantVisibilityRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustDeclareMethodVisibilityRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustDeclarePropertyVisibilityRule; +use Boundwize\StructArmed\Rule\Rules\File\MustUseLowercaseKeywordConstantRule; final readonly class Psr12Preset implements PresetInterface { @@ -20,6 +21,9 @@ public const PROPERTIES_MUST_DECLARE_VISIBILITY = 'psr12.properties.must_declare_visibility'; + public const FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS = + 'psr12.files.must_use_lowercase_keyword_constants'; + /** * @param list|null $sourcePaths */ @@ -41,6 +45,10 @@ public function apply(Architecture $architecture): void $layerName = $this->resolveLayerName($architecture, $sourcePathsForPsr12); $architecture->layer($layerName, $sourcePathsForPsr12 ?? []); + $architecture->rule( + self::FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS, + new MustUseLowercaseKeywordConstantRule($sourcePathsForPsr12) + ); $architecture->rule( self::METHODS_MUST_DECLARE_VISIBILITY, new MustDeclareMethodVisibilityRule($layerName) diff --git a/src/Rule/AbstractLayerAwareRule.php b/src/Rule/AbstractLayerAwareRule.php new file mode 100644 index 00000000..4faf2b40 --- /dev/null +++ b/src/Rule/AbstractLayerAwareRule.php @@ -0,0 +1,30 @@ + class name → class node */ + protected array $classNodeMap = []; + + /** @param array $classNodeMap */ + public function injectClassNodeMap(array $classNodeMap): void + { + $this->classNodeMap = $classNodeMap; + } + + /** The scanned node of a dependency, or null when it lies outside the scanned paths */ + protected function getDependencyNode(string $dependency): ?ClassNode + { + return $this->classNodeMap[$dependency] ?? null; + } +} diff --git a/src/Rule/AnonymousClassRuleInterface.php b/src/Rule/AnonymousClassRuleInterface.php new file mode 100644 index 00000000..949e0382 --- /dev/null +++ b/src/Rule/AnonymousClassRuleInterface.php @@ -0,0 +1,29 @@ +file; + $nodeVisitors = []; + + foreach ($ruleViolations as $ruleViolation) { + if ($ruleViolation->file !== $file) { + return false; + } + + $nodeVisitors[] = $this->createFixerVisitor($ruleViolation); + } + return $this->fixerProcessor()->process( - $ruleViolation->file, - $this->createFixerVisitor($ruleViolation), + $file, + $nodeVisitors, ); } diff --git a/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php b/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php index 8d881713..f97a55dd 100644 --- a/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php +++ b/src/Rule/Fixer/JsonRecast/JsonRecastFixerProcessor.php @@ -5,8 +5,12 @@ namespace Boundwize\StructArmed\Rule\Fixer\JsonRecast; use Boundwize\JsonRecast\JsonRecast; +use Boundwize\JsonRecast\JsonRecastResult; +use Boundwize\JsonRecast\Node\JsonDocument; +use Boundwize\JsonRecast\NodeTraverser\NodeJsonTraverser; use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitor; use Boundwize\JsonRecast\Parser\ParseError; +use RuntimeException; use function file_get_contents; use function file_put_contents; @@ -14,24 +18,40 @@ final readonly class JsonRecastFixerProcessor { - public function process(string $file, NodeJsonVisitor $nodeJsonVisitor): bool + /** @param NodeJsonVisitor|non-empty-list $nodeJsonVisitors */ + public function process(string $file, NodeJsonVisitor|array $nodeJsonVisitors): bool { if (! is_file($file)) { return false; } + if ($nodeJsonVisitors instanceof NodeJsonVisitor) { + $nodeJsonVisitors = [$nodeJsonVisitors]; + } + $json = (string) file_get_contents($file); try { - $result = JsonRecast::traverse( - JsonRecast::parse($json), - $nodeJsonVisitor - ); + $document = JsonRecast::parse($json); } catch (ParseError) { return false; } - $fixedJson = JsonRecast::print($result); + $nodeJsonTraverser = new NodeJsonTraverser(); + + foreach ($nodeJsonVisitors as $nodeJsonVisitor) { + $nodeJsonTraverser->addVisitor($nodeJsonVisitor); + } + + $nodeJsonTraversalResult = $nodeJsonTraverser->traverse($document); + + if (! $nodeJsonTraversalResult->node instanceof JsonDocument) { + throw new RuntimeException('JsonRecast fixer traversal must return JsonDocument.'); + } + + $jsonRecastResult = new JsonRecastResult($nodeJsonTraversalResult->node, $nodeJsonTraversalResult->changeSet); + + $fixedJson = JsonRecast::print($jsonRecastResult); return $fixedJson !== $json && file_put_contents($file, $fixedJson) !== false; } diff --git a/src/Rule/Fixer/PhpParser/AbstractPhpParserFixableRule.php b/src/Rule/Fixer/PhpParser/AbstractPhpParserFixableRule.php index a6edc60c..00c9fde8 100644 --- a/src/Rule/Fixer/PhpParser/AbstractPhpParserFixableRule.php +++ b/src/Rule/Fixer/PhpParser/AbstractPhpParserFixableRule.php @@ -10,13 +10,27 @@ abstract readonly class AbstractPhpParserFixableRule implements FixableInterface { - final public function fix(RuleViolation $ruleViolation): bool + /** + * Additional violations let the CLI fix one rule's violations for a file + * in a single read, parse, and write cycle. + */ + final public function fix(RuleViolation $ruleViolation, RuleViolation ...$additionalViolations): bool { - $nodeVisitor = $this->createFixerVisitor($ruleViolation); + $ruleViolations = [$ruleViolation, ...$additionalViolations]; + $file = $ruleViolation->file; + $nodeVisitors = []; + + foreach ($ruleViolations as $ruleViolation) { + if ($ruleViolation->file !== $file) { + return false; + } + + $nodeVisitors[] = $this->createFixerVisitor($ruleViolation); + } return $this->fixerProcessor()->process( - $ruleViolation->file, - $nodeVisitor, + $file, + $nodeVisitors, $this->shouldRemoveFileWhenEmpty(), ); } diff --git a/src/Rule/Fixer/PhpParser/AbstractTokenAwareVisitor.php b/src/Rule/Fixer/PhpParser/AbstractTokenAwareVisitor.php new file mode 100644 index 00000000..3280662d --- /dev/null +++ b/src/Rule/Fixer/PhpParser/AbstractTokenAwareVisitor.php @@ -0,0 +1,27 @@ + The mutable tokens the file being fixed was parsed into */ + protected array $tokens = []; + + /** @param array $tokens */ + public function setTokens(array $tokens): void + { + $this->tokens = $tokens; + } +} diff --git a/src/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitor.php b/src/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitor.php new file mode 100644 index 00000000..43952428 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitor.php @@ -0,0 +1,58 @@ +namespacedName?->toString() !== $this->className) { + return null; + } + + foreach ($node->getConstants() as $classConstant) { + if (! $this->containsConstant($classConstant)) { + continue; + } + + if (($classConstant->flags & Modifiers::PROTECTED) === 0) { + return null; + } + + $classConstant->flags = ($classConstant->flags & ~Modifiers::PROTECTED) | Modifiers::PRIVATE; + + return $node; + } + + return null; + } + + private function containsConstant(ClassConst $classConst): bool + { + foreach ($classConst->consts as $constant) { + if ($constant->name->toString() === $this->constantName) { + return true; + } + } + + return false; + } +} diff --git a/src/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitor.php b/src/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitor.php new file mode 100644 index 00000000..2b1d7f87 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitor.php @@ -0,0 +1,44 @@ +namespacedName?->toString() !== $this->className) { + return null; + } + + $classMethod = $node->getMethod($this->methodName); + if (! $classMethod instanceof ClassMethod) { + return null; + } + + if (($classMethod->flags & Modifiers::PROTECTED) === 0) { + return null; + } + + $classMethod->flags = ($classMethod->flags & ~Modifiers::PROTECTED) | Modifiers::PRIVATE; + + return $node; + } +} diff --git a/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php new file mode 100644 index 00000000..4488a9f3 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/Class_/RemoveAnonymousClassParenthesesVisitor.php @@ -0,0 +1,58 @@ +isAnonymous() || $node->getStartLine() !== $this->line) { + return null; + } + + $range = AnonymousClassParentheses::emptyTokenRange($this->tokens, $node); + + if ($range === null) { + return null; + } + + // A range always holds the `(` and `)` tokens with their text, so + // blanking it is always a change; an already-fixed class yields no range. + [$first, $last] = $range; + + for ($index = $first; $index <= $last; $index++) { + $this->tokens[$index]->text = ''; + } + + // `new class(){}` keeps a space between the keyword and what follows. + if (! isset($this->tokens[$last + 1]) || $this->tokens[$last + 1]->id !== T_WHITESPACE) { + $this->tokens[$last]->text = ' '; + } + + return $node; + } +} diff --git a/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php b/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php new file mode 100644 index 00000000..78d841c2 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitor.php @@ -0,0 +1,67 @@ + true, + 'false' => true, + 'null' => true, + ]; + + /** + * @param string|null $spelling The spelling as written without a leading `\`, e.g. `TRUE`. + * A violation without it does not identify an occurrence, so + * the visitor then changes nothing rather than widening the fix. + */ + public function __construct( + private readonly int $line, + private readonly ?string $spelling, + ) { + } + + public function enterNode(Node $node): ?Node + { + if ( + $this->spelling === null + || ! $node instanceof ConstFetch + || $node->getStartLine() !== $this->line + || $node->name instanceof Relative + ) { + return null; + } + + $name = $node->name; + $spelling = $name->toString(); + $keyword = strtolower($spelling); + + if (! isset(self::KEYWORD_CONSTANTS[$keyword]) || $spelling === $keyword || $spelling !== $this->spelling) { + return null; + } + + $node->name = $name instanceof FullyQualified + ? new FullyQualified($keyword, $name->getAttributes()) + : new Name($keyword, $name->getAttributes()); + + return $node; + } +} diff --git a/src/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitor.php b/src/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitor.php new file mode 100644 index 00000000..d2771713 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitor.php @@ -0,0 +1,82 @@ +static || $node->getStartLine() !== $this->line) { + return null; + } + + if ($this->usesThis($node)) { + return null; + } + + $node->static = true; + + return $node; + } + + /** + * Whether the body reads `$this`, including through nested closures. + * `$this` inside a nested anonymous class body is that class's own, so + * anonymous classes are not descended into. + */ + private function usesThis(Closure|ArrowFunction $anonymousFunction): bool + { + $thisFinder = new class extends NodeVisitorAbstract { + public bool $found = false; + + public function enterNode(Node $node): ?int + { + if ($node instanceof Class_) { + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + if ($node instanceof Variable && $node->name === 'this') { + $this->found = true; + + return NodeVisitor::STOP_TRAVERSAL; + } + + return null; + } + }; + + (new NodeTraverser($thisFinder))->traverse([$anonymousFunction]); + + return $thisFinder->found; + } +} diff --git a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php index e69db504..f0450cdf 100644 --- a/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php +++ b/src/Rule/Fixer/PhpParser/PhpParserFixerProcessor.php @@ -6,6 +6,7 @@ use PhpParser\Error; use PhpParser\Node; +use PhpParser\Node\Scalar\Float_; use PhpParser\Node\Stmt\Declare_; use PhpParser\Node\Stmt\GroupUse; use PhpParser\Node\Stmt\Namespace_; @@ -21,16 +22,22 @@ use function file_get_contents; use function file_put_contents; use function is_file; +use function is_string; use function unlink; final readonly class PhpParserFixerProcessor { - public function process(string $file, NodeVisitor $nodeVisitor, bool $removeFileWhenEmpty = false): bool + /** @param NodeVisitor|non-empty-list $nodeVisitors */ + public function process(string $file, NodeVisitor|array $nodeVisitors, bool $removeFileWhenEmpty = false): bool { if (! is_file($file)) { return false; } + if ($nodeVisitors instanceof NodeVisitor) { + $nodeVisitors = [$nodeVisitors]; + } + $code = (string) file_get_contents($file); $parser = (new ParserFactory())->createForNewestSupportedVersion(); @@ -45,10 +52,20 @@ public function process(string $file, NodeVisitor $nodeVisitor, bool $removeFile return false; } - $nameResolver = new NameResolver(options: ['replaceNodes' => false]); - $statements = (new NodeTraverser($nameResolver, $nodeVisitor)) - ->traverse((new NodeTraverser(new CloningVisitor())) - ->traverse($originalStatements)); + $tokens = $parser->getTokens(); + $statements = (new NodeTraverser(new CloningVisitor()))->traverse($originalStatements); + $statements = (new NodeTraverser(new NameResolver(options: ['replaceNodes' => false]))) + ->traverse($statements); + + foreach ($nodeVisitors as $nodeVisitor) { + // A token edit lands in the output through the same tokens the + // format-preserving printer copies unchanged code from. + if ($nodeVisitor instanceof AbstractTokenAwareVisitor) { + $nodeVisitor->setTokens($tokens); + } + + $statements = (new NodeTraverser($nodeVisitor))->traverse($statements); + } // A fix that removes the last declaration leaves only boilerplate // (declare/namespace/use); the whole file is dead weight at that point. @@ -56,7 +73,20 @@ public function process(string $file, NodeVisitor $nodeVisitor, bool $removeFile return unlink($file); } - $fixedCode = (new Standard())->printFormatPreserving($statements, $originalStatements, $parser->getTokens()); + $prettyPrinter = new class extends Standard { + // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps -- PHP-Parser extension hook. + protected function pScalar_Float(Float_ $node): string + { + $rawValue = $node->getAttribute('rawValue'); + + if ($node->getAttribute('shouldPrintRawValue') === true && is_string($rawValue)) { + return $rawValue; + } + + return parent::pScalar_Float($node); + } + }; + $fixedCode = $prettyPrinter->printFormatPreserving($statements, $originalStatements, $tokens); return $fixedCode !== $code && file_put_contents($file, $fixedCode) !== false; } diff --git a/src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php b/src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php new file mode 100644 index 00000000..1ed42459 --- /dev/null +++ b/src/Rule/Fixer/PhpParser/Scalar/AddNumericLiteralSeparatorsVisitor.php @@ -0,0 +1,56 @@ +shouldSkip($node)) { + return null; + } + + $attributes = $node->getAttributes(); + unset($attributes['origNode']); + $attributes['rawValue'] = $this->replacement; + $attributes['shouldPrintRawValue'] = true; + + return $node instanceof Int_ + ? new Int_($node->value, $attributes) + : new Float_($node->value, $attributes); + } + + /** + * @phpstan-assert-if-false Int_|Float_ $node + */ + private function shouldSkip(Node $node): bool + { + return $this->literal === null + || $this->replacement === null + || (! $node instanceof Int_ && ! $node instanceof Float_) + || $node->getStartLine() !== $this->line + || ($node instanceof Int_ && $node->getAttribute('kind') !== Int_::KIND_DEC) + || $node->getAttribute('rawValue') !== $this->literal + || str_replace('_', '', $this->replacement) !== $this->literal; + } +} diff --git a/src/Rule/FunctionRuleInterface.php b/src/Rule/FunctionRuleInterface.php new file mode 100644 index 00000000..480bd0ab --- /dev/null +++ b/src/Rule/FunctionRuleInterface.php @@ -0,0 +1,28 @@ + $classNodeMap class name → class node */ - public function injectClassNodeMap(array $classNodeMap): void; -} diff --git a/src/Rule/RuleViolation.php b/src/Rule/RuleViolation.php index f16ef189..f0a95e44 100644 --- a/src/Rule/RuleViolation.php +++ b/src/Rule/RuleViolation.php @@ -19,6 +19,8 @@ public function __construct( public ?string $methodName = null, public ?string $constantName = null, public ?string $propertyName = null, + public ?string $functionName = null, + public ?string $numericLiteral = null, ) { } @@ -61,6 +63,14 @@ public function toArray(): array $data['property'] = $this->propertyName; } + if ($this->functionName !== null) { + $data['function'] = $this->functionName; + } + + if ($this->numericLiteral !== null) { + $data['numericLiteral'] = $this->numericLiteral; + } + return $data; } } diff --git a/src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php b/src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php new file mode 100644 index 00000000..7b71efd3 --- /dev/null +++ b/src/Rule/Rules/Class_/AnonymousClassMayNotHaveEmptyParenthesesRule.php @@ -0,0 +1,56 @@ +isInLayer($this->layer); + } + + public function evaluate(AnonymousClassNode $anonymousClassNode): ?RuleViolation + { + if (! $anonymousClassNode->hasEmptyParentheses) { + return null; + } + + return new RuleViolation( + message: sprintf( + 'Anonymous class in [%s] may not have empty parentheses after `class`', + $anonymousClassNode->enclosingScopeName() + ), + file: $anonymousClassNode->file, + line: $anonymousClassNode->line, + className: $anonymousClassNode->enclosingScopeName(), + layer: $anonymousClassNode->layer, + ); + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): RemoveAnonymousClassParenthesesVisitor + { + return new RemoveAnonymousClassParenthesesVisitor($ruleViolation->line); + } +} diff --git a/src/Rule/Rules/Class_/EnumCaseNameMustBePascalCaseRule.php b/src/Rule/Rules/Class_/EnumCaseNameMustBePascalCaseRule.php new file mode 100644 index 00000000..d9be2e96 --- /dev/null +++ b/src/Rule/Rules/Class_/EnumCaseNameMustBePascalCaseRule.php @@ -0,0 +1,64 @@ +isInLayer($this->layer) + && $classNode->isEnum; + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + return $this->evaluateAll($classNode)[0] ?? null; + } + + /** + * @return list + */ + public function evaluateAll(ClassNode $classNode): array + { + $violations = []; + + foreach ($classNode->enumCases as $enumCase) { + if ((bool) preg_match('/^[A-Z][A-Za-z0-9]*$/', $enumCase->name)) { + continue; + } + + $violations[] = new RuleViolation( + message: sprintf( + 'Enum case [%s::%s] must be declared in PascalCase', + $classNode->className, + $enumCase->name + ), + file: $classNode->file, + line: $enumCase->line !== 0 ? $enumCase->line : $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + ); + } + + return $violations; + } +} diff --git a/src/Rule/Rules/Class_/EnumConstantMayNotBeProtectedRule.php b/src/Rule/Rules/Class_/EnumConstantMayNotBeProtectedRule.php new file mode 100644 index 00000000..ae87108c --- /dev/null +++ b/src/Rule/Rules/Class_/EnumConstantMayNotBeProtectedRule.php @@ -0,0 +1,79 @@ +isInLayer($this->layer) + && $classNode->isEnum; + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + return $this->evaluateAll($classNode)[0] ?? null; + } + + /** + * @return list + */ + public function evaluateAll(ClassNode $classNode): array + { + $violations = []; + + foreach ($classNode->constants as $constant) { + if ($constant->visibility !== 'protected') { + continue; + } + + $violations[] = new RuleViolation( + message: sprintf( + 'Enum constant [%s::%s] may not be declared protected, use private instead', + $classNode->className, + $constant->name + ), + file: $classNode->file, + line: $constant->line !== 0 ? $constant->line : $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + constantName: $constant->name, + ); + } + + return $violations; + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): ChangeProtectedConstantToPrivateVisitor + { + /** @var string $constantName */ + $constantName = $ruleViolation->constantName; + + return new ChangeProtectedConstantToPrivateVisitor( + $ruleViolation->className, + $constantName + ); + } +} diff --git a/src/Rule/Rules/Class_/EnumMethodMayNotBeProtectedRule.php b/src/Rule/Rules/Class_/EnumMethodMayNotBeProtectedRule.php new file mode 100644 index 00000000..def4034c --- /dev/null +++ b/src/Rule/Rules/Class_/EnumMethodMayNotBeProtectedRule.php @@ -0,0 +1,78 @@ +isInLayer($this->layer) + && $classNode->isEnum; + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + return $this->evaluateAll($classNode)[0] ?? null; + } + + /** + * @return list + */ + public function evaluateAll(ClassNode $classNode): array + { + $violations = []; + + foreach ($classNode->methods as $method) { + if ($method->visibility !== 'protected') { + continue; + } + + $violations[] = new RuleViolation( + message: sprintf( + 'Enum method [%s::%s] may not be declared protected, use private instead', + $classNode->className, + $method->name + ), + file: $classNode->file, + line: $method->line !== 0 ? $method->line : $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + methodName: $method->name, + ); + } + + return $violations; + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): ChangeProtectedMethodToPrivateVisitor + { + /** @var string $methodName */ + $methodName = $ruleViolation->methodName; + + return new ChangeProtectedMethodToPrivateVisitor( + $ruleViolation->className, + $methodName + ); + } +} diff --git a/src/Rule/Rules/Class_/MayNotExtendClassRule.php b/src/Rule/Rules/Class_/MayNotExtendClassRule.php new file mode 100644 index 00000000..f522b04e --- /dev/null +++ b/src/Rule/Rules/Class_/MayNotExtendClassRule.php @@ -0,0 +1,40 @@ +isClass() && $classNode->isInLayer($this->layer); + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + if (! $classNode->extendsClass($this->class)) { + return null; + } + + return new RuleViolation( + message: sprintf('Class [%s] must not extend class [%s]', $classNode->className, $this->class), + file: $classNode->file, + line: $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + ); + } +} diff --git a/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php b/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php index 633cdca0..27e32319 100644 --- a/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php +++ b/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php @@ -13,7 +13,6 @@ use Boundwize\StructArmed\Util\Path; use function dirname; -use function file_exists; use function implode; use function is_dir; use function rtrim; @@ -28,22 +27,10 @@ public function __construct( public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation { - $composerFile = Path::normalise(rtrim($basePath, '/') . '/composer.json', canonicalise: true); - - if (! file_exists($composerFile)) { - return $this->violation( - 'composer.json was not found', - $composerFile - ); - } - $composer = $this->psr4PathResolver->composerConfig($basePath); if ($composer === null) { - return $this->violation( - 'composer.json is not valid JSON', - $composerFile - ); + return null; } $nonExistentPaths = []; @@ -58,6 +45,7 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar return null; } + $composerFile = Path::normalise(rtrim($basePath, '/') . '/composer.json', canonicalise: true); return $this->violation( sprintf( 'PSR-4 source path(s) [%s] declared in composer.json do not exist on disk', diff --git a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php index 8f84fce5..26f58873 100644 --- a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php +++ b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php @@ -11,7 +11,6 @@ use Boundwize\StructArmed\Util\Path; use function array_map; -use function file_exists; use function implode; use function in_array; use function rtrim; @@ -34,22 +33,10 @@ public function __construct( public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation { - $composerFile = Path::normalise(rtrim($basePath, '/') . '/composer.json', canonicalise: true); - - if (! file_exists($composerFile)) { - return $this->violation( - 'composer.json was not found', - $composerFile - ); - } - $composer = $this->psr4PathResolver->composerConfig($basePath); if ($composer === null) { - return $this->violation( - 'composer.json is not valid JSON', - $composerFile - ); + return null; } if ($this->sourcePaths === null) { @@ -72,6 +59,7 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar return null; } + $composerFile = Path::normalise(rtrim($basePath, '/') . '/composer.json', canonicalise: true); return $this->violation( sprintf( 'PSR-4 source path(s) [%s] must exist in composer.json autoload or autoload-dev', diff --git a/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php b/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php new file mode 100644 index 00000000..0b736ac1 --- /dev/null +++ b/src/Rule/Rules/File/LargeNumericLiteralMustUseSeparatorRule.php @@ -0,0 +1,155 @@ +|null $sourcePaths + */ + public function __construct( + private int $minimum = 1_000_000, + ?array $sourcePaths = null, + ?PhpFileFinder $phpFileFinder = null, + ) { + if ($this->minimum < 1) { + throw new InvalidArgumentException('The minimum must be a positive integer.'); + } + + $this->phpFileFinder = $phpFileFinder ?? new PhpFileFinder($sourcePaths); + } + + public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation + { + return $this->evaluateProjectAll($basePath, $architecture, $skipPaths)[0] ?? null; + } + + /** + * @param list $skipPaths + * @return list + */ + public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array + { + $files = $this->phpFileFinder->files($basePath, $skipPaths); + $extractionResult = (new AnalysisNodeExtractor())->extract($files); + + return $this->evaluateFiles($files, new FileAnalysisProvider($extractionResult->fileAnalyses)); + } + + /** + * @param list $skipPaths + * @return list + */ + public function evaluateProjectAllWithProvider( + string $basePath, + Architecture $architecture, + FileAnalysisProvider $fileAnalysisProvider, + array $skipPaths = [], + ): array { + return $this->evaluateFiles( + $this->phpFileFinder->filesFromScope( + $basePath, + $fileAnalysisProvider->scopeFiles(), + $skipPaths, + ), + $fileAnalysisProvider, + ); + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): NodeVisitor + { + $literal = $ruleViolation->numericLiteral; + + return new AddNumericLiteralSeparatorsVisitor( + $ruleViolation->line, + $literal, + $literal !== null ? $this->formatDecimalLiteral($literal) : null, + ); + } + + /** + * @param list $files + * @return list + */ + private function evaluateFiles(array $files, FileAnalysisProvider $fileAnalysisProvider): array + { + $violations = []; + + foreach ($files as $file) { + $fileAnalysis = $fileAnalysisProvider->analyse($file); + $fileAnalysisProvider->releaseAst($file); + + foreach ($fileAnalysis->numericLiterals as [$line, $literal, $value]) { + if (abs($value) < $this->minimum || str_contains($literal, '_')) { + continue; + } + + $replacement = $this->formatDecimalLiteral($literal); + + if ($replacement === null || $replacement === $literal) { + continue; + } + + $violations[] = new RuleViolation( + message: sprintf( + 'Numeric literal [%s] must use separator formatting [%s]', + $literal, + $replacement, + ), + file: $file, + line: $line, + className: '', + numericLiteral: $literal, + ); + } + } + + return $violations; + } + + private function formatDecimalLiteral(string $literal): ?string + { + if (preg_match('/^(?:0|[1-9][0-9]*)$/D', $literal) === 1) { + return $this->groupDigits($literal); + } + + if (preg_match('/^([0-9]+)\.([0-9]*)$/D', $literal, $matches) !== 1) { + return null; + } + + return $this->groupDigits($matches[1]) . '.' . $matches[2]; + } + + private function groupDigits(string $digits): string + { + return strrev(implode('_', str_split(strrev($digits), 3))); + } +} diff --git a/src/Rule/Rules/File/MustUseLowercaseKeywordConstantRule.php b/src/Rule/Rules/File/MustUseLowercaseKeywordConstantRule.php new file mode 100644 index 00000000..9c852238 --- /dev/null +++ b/src/Rule/Rules/File/MustUseLowercaseKeywordConstantRule.php @@ -0,0 +1,114 @@ +|null $sourcePaths + */ + public function __construct( + ?array $sourcePaths = null, + ?PhpFileFinder $phpFileFinder = null, + ) { + $this->phpFileFinder = $phpFileFinder ?? new PhpFileFinder($sourcePaths); + } + + public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation + { + return $this->evaluateProjectAll($basePath, $architecture, $skipPaths)[0] ?? null; + } + + /** + * @param list $skipPaths + * @return RuleViolation[] + */ + public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array + { + $files = $this->phpFileFinder->files($basePath, $skipPaths); + + // Outside the analyser the spellings come from the same traversal it + // runs, so there is a single place that recognises them. + $extractionResult = (new AnalysisNodeExtractor())->extract($files); + + return $this->evaluateFiles($files, new FileAnalysisProvider($extractionResult->fileAnalyses)); + } + + /** + * @param list $skipPaths + * @return RuleViolation[] + */ + public function evaluateProjectAllWithProvider( + string $basePath, + Architecture $architecture, + FileAnalysisProvider $fileAnalysisProvider, + array $skipPaths = [], + ): array { + return $this->evaluateFiles( + $this->phpFileFinder->filesFromScope( + $basePath, + $fileAnalysisProvider->scopeFiles(), + $skipPaths, + ), + $fileAnalysisProvider, + ); + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): NodeVisitor + { + return new LowercaseKeywordConstantVisitor($ruleViolation->line, $ruleViolation->constantName); + } + + /** + * @param list $files + * @return list + */ + private function evaluateFiles(array $files, FileAnalysisProvider $fileAnalysisProvider): array + { + $violations = []; + + foreach ($files as $file) { + $fileAnalysis = $fileAnalysisProvider->analyse($file); + $fileAnalysisProvider->releaseAst($file); + + foreach ($fileAnalysis->nonCanonicalKeywordConstants as [$line, $spelling]) { + $violations[] = new RuleViolation( + message: sprintf( + 'Keyword constant [%s] must use lowercase [%s]', + $spelling, + strtolower($spelling), + ), + file: $file, + line: $line, + className: '', + constantName: ltrim($spelling, '\\'), + ); + } + } + + return $violations; + } +} diff --git a/src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php b/src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php new file mode 100644 index 00000000..672f588b --- /dev/null +++ b/src/Rule/Rules/Function_/MustBeStaticAnonymousFunctionRule.php @@ -0,0 +1,57 @@ +isInLayer($this->layer); + } + + public function evaluate(AnonymousFunctionNode $anonymousFunctionNode): ?RuleViolation + { + // A closure reading `$this` cannot be static: PHP raises an error + // when a static closure accesses `$this`. + if ($anonymousFunctionNode->isStatic || $anonymousFunctionNode->usesThis) { + return null; + } + + return new RuleViolation( + message: sprintf( + '%s in [%s] must be declared static', + $anonymousFunctionNode->getType(), + $anonymousFunctionNode->enclosingScopeName() + ), + file: $anonymousFunctionNode->file, + line: $anonymousFunctionNode->line, + className: $anonymousFunctionNode->enclosingScopeName(), + layer: $anonymousFunctionNode->layer, + ); + } + + protected function createFixerVisitor(RuleViolation $ruleViolation): AddStaticAnonymousFunctionVisitor + { + return new AddStaticAnonymousFunctionVisitor($ruleViolation->line); + } +} diff --git a/src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php b/src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php new file mode 100644 index 00000000..22c40fd4 --- /dev/null +++ b/src/Rule/Rules/Function_/MustHaveReturnTypeFunctionRule.php @@ -0,0 +1,43 @@ +isInLayer($this->layer); + } + + public function evaluate(FunctionNode $functionNode): ?RuleViolation + { + if ($functionNode->hasReturnType) { + return null; + } + + return new RuleViolation( + message: sprintf( + 'Function [%s()] must declare a return type', + $functionNode->functionName + ), + file: $functionNode->file, + line: $functionNode->line, + className: $functionNode->functionName, + layer: $functionNode->layer, + functionName: $functionNode->functionName, + ); + } +} diff --git a/src/Rule/Rules/Layer/MayNotDependOnRule.php b/src/Rule/Rules/Layer/MayNotDependOnRule.php index ed1a91ed..d7eed8e1 100644 --- a/src/Rule/Rules/Layer/MayNotDependOnRule.php +++ b/src/Rule/Rules/Layer/MayNotDependOnRule.php @@ -5,7 +5,7 @@ namespace Boundwize\StructArmed\Rule\Rules\Layer; use Boundwize\StructArmed\Analyser\ClassNode; -use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; +use Boundwize\StructArmed\Rule\AbstractLayerAwareRule; use Boundwize\StructArmed\Rule\MultipleRuleViolationInterface; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Util\Path; @@ -15,13 +15,10 @@ use function str_contains; use function str_starts_with; -final class MayNotDependOnRule implements MultipleRuleViolationInterface, LayerAwareRuleInterface +final class MayNotDependOnRule extends AbstractLayerAwareRule implements MultipleRuleViolationInterface { private readonly string $normalisedToPath; - /** @var array */ - private array $classNodeMap = []; - public function __construct( private readonly string $from, private readonly string $to, @@ -30,12 +27,6 @@ public function __construct( $this->normalisedToPath = Path::normalise($toPath ?? $to); } - /** @param array $classNodeMap */ - public function injectClassNodeMap(array $classNodeMap): void - { - $this->classNodeMap = $classNodeMap; - } - public function appliesTo(ClassNode $classNode): bool { return $classNode->isInLayer($this->from); @@ -82,7 +73,7 @@ className: $classNode->className, private function isInForbiddenLayer(string $dependency): bool { // Priority 1: Use the scanned dependency node if available - $dependencyNode = $this->classNodeMap[$dependency] ?? null; + $dependencyNode = $this->getDependencyNode($dependency); if ($dependencyNode instanceof ClassNode && $dependencyNode->layers !== []) { return in_array($this->to, $dependencyNode->layers, true); diff --git a/src/Util/PhpParser/AnonymousClassParentheses.php b/src/Util/PhpParser/AnonymousClassParentheses.php new file mode 100644 index 00000000..aa4e1a24 --- /dev/null +++ b/src/Util/PhpParser/AnonymousClassParentheses.php @@ -0,0 +1,99 @@ + $tokens + * @return array{int, int}|null + */ + public static function emptyTokenRange(array $tokens, Class_ $class): ?array + { + // An attribute argument may hold `Foo::class`, another T_CLASS token, + // so the keyword is searched for after the last attribute group. + $attrGroups = $class->attrGroups; + $index = $attrGroups === [] + ? $class->getStartTokenPos() + : end($attrGroups)->getEndTokenPos() + 1; + + // Modifiers (`new readonly class`) still precede the keyword. + while (isset($tokens[$index]) && $tokens[$index]->id !== T_CLASS) { + $index++; + } + + if (! isset($tokens[$index])) { + return null; + } + + $keyword = $index; + $open = self::skipWhitespaceAndComments($tokens, $keyword + 1); + + if (! isset($tokens[$open]) || $tokens[$open]->text !== '(') { + return null; + } + + $close = self::skipWhitespace($tokens, $open + 1); + + if (! isset($tokens[$close]) || $tokens[$close]->text !== ')') { + return null; + } + + // The whitespace before `(` goes too, back to the keyword or to a + // comment in between, which stays. + $first = $open; + + while ($first - 1 > $keyword && $tokens[$first - 1]->id === T_WHITESPACE) { + $first--; + } + + return [$first, $close]; + } + + /** @param array $tokens */ + private static function skipWhitespace(array $tokens, int $index): int + { + while (isset($tokens[$index]) && $tokens[$index]->id === T_WHITESPACE) { + $index++; + } + + return $index; + } + + /** @param array $tokens */ + private static function skipWhitespaceAndComments(array $tokens, int $index): int + { + while ( + isset($tokens[$index]) + && (in_array($tokens[$index]->id, [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) + ) { + $index++; + } + + return $index; + } +} diff --git a/structarmed.php b/structarmed.php index baa6adcd..d20dd493 100644 --- a/structarmed.php +++ b/structarmed.php @@ -4,6 +4,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Preset\Preset; +use Boundwize\StructArmed\Preset\Presets\CodeQualityPreset; use Boundwize\StructArmed\Preset\Presets\Psr1Preset; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; @@ -27,9 +28,9 @@ ->layer('Rule', 'src/Rule/') ->layer('Util', 'src/Util/') ->ruleset([ - 'Analyser' => ['+Cache', 'Composer', 'LayerResolver', 'Progress', 'Util'], + 'Analyser' => ['+Cache', 'Composer', 'LayerResolver', 'Progress'], 'Baseline' => ['Core', 'Rule', 'Util'], - 'Cache' => ['Analyser', 'Core', 'Rule', 'Util'], + 'Cache' => ['Analyser', 'Composer', 'Core', 'Rule', 'Util'], 'Cli' => ['Baseline', '+Cache', 'Config', 'Progress', 'Report', 'Util'], 'Composer' => ['Util'], 'Config' => ['Core'], @@ -45,15 +46,18 @@ ]) ->skip([ 'tests/Fixtures/', - Psr1Preset::METHODS_MUST_BE_CAMEL_CASE => [ + Psr1Preset::METHODS_MUST_BE_CAMEL_CASE => [ __DIR__ . '/src/Preset/Preset.php', ], - Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS => [ - __DIR__ . '/tests/Analyser/Parallel/ParallelClassNodeExtractorTest.php', + Psr1Preset::FILES_SHOULD_DECLARE_SYMBOLS_OR_SIDE_EFFECTS => [ + __DIR__ . '/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php', __DIR__ . '/tests/Analyser/Parallel/MockFunctions.php', ], + CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR => [ + __DIR__ . '/tests', + ], ]) - ->withPresets(Preset::PSR1(), Preset::PSR12(), Preset::PSR4(), Preset::YAGNI()) + ->withPresets(Preset::PER(), Preset::YAGNI(), Preset::CODEQUALITY()) ->rule( 'source.must_be_final', new MustBeFinalRule(layer: 'Source') diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index c58af1c1..1c7f6d89 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -6,14 +6,18 @@ use Boundwize\StructArmed\Analyser\Analyser; use Boundwize\StructArmed\Analyser\AnalyserOptions; +use Boundwize\StructArmed\Analyser\AnonymousClassNode; +use Boundwize\StructArmed\Analyser\AnonymousFunctionNode; use Boundwize\StructArmed\Analyser\FileAnalysisProvider; -use Boundwize\StructArmed\Analyser\Parallel\ParallelClassNodeExtractor; +use Boundwize\StructArmed\Analyser\FunctionNode; +use Boundwize\StructArmed\Analyser\Parallel\ParallelAnalysisNodeExtractor; use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Cache\AnalysisResultCache; use Boundwize\StructArmed\Cache\FileHashProvider; use Boundwize\StructArmed\File\PhpFileCollector; use Boundwize\StructArmed\File\SkipPathMatcher; use Boundwize\StructArmed\Preset\Preset; +use Boundwize\StructArmed\Preset\Presets\DddPreset; use Boundwize\StructArmed\Preset\Presets\MvcPreset; use Boundwize\StructArmed\Preset\Presets\Psr12Preset; use Boundwize\StructArmed\Preset\Presets\Psr15Preset; @@ -21,7 +25,11 @@ use Boundwize\StructArmed\Preset\Presets\Psr4Preset; use Boundwize\StructArmed\Preset\Presets\YagniPreset; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; +use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface; +use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; +use Boundwize\StructArmed\Rule\FunctionRuleInterface; +use Boundwize\StructArmed\Rule\Rules\Class_\AnonymousClassMayNotHaveEmptyParenthesesRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4SourcePathsRule; use Boundwize\StructArmed\Rule\Rules\File\Psr1PhpTagsRule; @@ -38,7 +46,9 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function array_filter; use function array_map; +use function array_values; use function count; use function dirname; use function file_put_contents; @@ -47,6 +57,7 @@ use function realpath; use function rename; use function sort; +use function sprintf; use function str_replace; use function symlink; use function unlink; @@ -54,13 +65,397 @@ use const DIRECTORY_SEPARATOR; #[CoversClass(Analyser::class)] -#[CoversClass(ParallelClassNodeExtractor::class)] +#[CoversClass(ParallelAnalysisNodeExtractor::class)] #[CoversClass(PhpFileCollector::class)] #[CoversClass(SkipPathMatcher::class)] final class AnalyserTest extends TestCase { use TemporaryDirectoryCleanupTrait; + /** + * A rule flagging every named function and every anonymous function that + * accesses a superglobal, implementing both function-like interfaces. + */ + private function makeNoSuperglobalsInFunctionsRule(): FunctionRuleInterface&AnonymousFunctionRuleInterface + { + return new class implements FunctionRuleInterface, AnonymousFunctionRuleInterface { + public function appliesTo(FunctionNode|AnonymousFunctionNode $node): bool + { + return $node->isInLayer('Source'); + } + + public function evaluate(FunctionNode|AnonymousFunctionNode $node): ?RuleViolation + { + if (! $node->accessesSuperglobals()) { + return null; + } + + if ($node instanceof FunctionNode) { + return new RuleViolation( + message: 'Function [' . $node->functionName . '] must not access superglobals', + file: $node->file, + line: $node->line, + className: $node->functionName, + layer: $node->layer, + functionName: $node->functionName, + ); + } + + return new RuleViolation( + message: $node->getType() . ' in [' + . $node->enclosingScopeName() + . '] must not access superglobals', + file: $node->file, + line: $node->line, + className: $node->enclosingScopeName(), + layer: $node->layer, + ); + } + }; + } + + /** @return array */ + private function functionRuleProjectFiles(): array + { + return [ + 'src/helpers.php' => ' ' $_SERVER["z"]; }' . "\n" + . '}' . "\n", + 'src/Skipped/skip.php' => ' $_GET["x"];' . "\n", + ]; + } + + public function testFunctionRulesSkipNodesTheyDoNotApplyTo(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles() + [ + 'other/helpers.php' => ' $_POST["y"];' . "\n", + ]); + + // The rule applies to the Source layer only; nodes in Other are seen + // by the analyser but the rule declines them. + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->layer('Other', 'other/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skip(['functions.no_superglobals' => ['src/Skipped/']]); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + + $this->assertCount(3, $violations); + + foreach ($violations as $violation) { + $this->assertStringNotContainsString('/other/', $this->normalisePath($violation->file)); + } + } + + public function testFunctionRulesHonourGlobalSkipPathsForPreResolvedFiles(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + + // A caller-supplied file list bypasses file discovery, so a globally + // skipped file can reach extraction; its nodes must still be skipped. + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skipPaths(['src/Skipped/']); + + $files = [ + $basePath . '/src/helpers.php', + $basePath . '/src/Handler.php', + $basePath . '/src/Skipped/skip.php', + ]; + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential(), $files) + ->forRule('functions.no_superglobals'); + + $this->assertCount(3, $violations); + + foreach ($violations as $violation) { + $this->assertStringNotContainsString('/Skipped/', $this->normalisePath($violation->file)); + } + } + + public function testFunctionRulesAreEvaluatedAgainstFunctionsAndAnonymousFunctions(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skip(['functions.no_superglobals' => ['src/Skipped/']]); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $analyserOptions) { + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, $analyserOptions) + ->forRule('functions.no_superglobals'); + + $messages = array_map( + static fn(RuleViolation $ruleViolation): string => $ruleViolation->message, + $violations + ); + sort($messages); + + $this->assertSame([ + 'Arrow function in [App\\Handler] must not access superglobals', + 'Closure in [file scope] must not access superglobals', + 'Function [App\\dirty] must not access superglobals', + ], $messages); + + foreach ($violations as $violation) { + $this->assertSame('functions.no_superglobals', $violation->ruleKey); + $this->assertSame('Source', $violation->layer); + $this->assertFalse($violation->fixable); + } + + $functionViolation = array_values(array_filter( + $violations, + static fn(RuleViolation $ruleViolation): bool => $ruleViolation->functionName !== null + )); + + $this->assertCount(1, $functionViolation); + $this->assertSame('App\\dirty', $functionViolation[0]->functionName); + $this->assertSame(4, $functionViolation[0]->line); + } + } + + public function testFunctionRulesHonourGlobalSkipPaths(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skipPaths(['src/helpers.php', 'src/Skipped/']); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + + $this->assertCount(1, $violations); + $this->assertStringEndsWith('/src/Handler.php', $this->normalisePath($violations[0]->file)); + } + + public function testFunctionRuleViolationsSurviveTheAnalysisNodeCache(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + $analysisResultCache = new AnalysisResultCache($basePath, new FileHashProvider(), 'cache'); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skip(['functions.no_superglobals' => ['src/Skipped/']]); + + $coldViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + $warmViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + + $this->assertCount(3, $coldViolations); + $this->assertEquals($coldViolations, $warmViolations); + } + + public function testFunctionRuleViolationsSurviveTheAnalysisNodeCacheWithFileAnalysis(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + $analysisResultCache = new AnalysisResultCache($basePath, new FileHashProvider(), 'cache'); + + // A file-analysis rule makes the warm run load nodes through the + // file-analysis cache path, which must also restore function-likes. + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->rule('psr1.php_tags', new Psr1PhpTagsRule(['src/'])) + ->skip(['functions.no_superglobals' => ['src/Skipped/']]); + + $coldViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + $warmViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('functions.no_superglobals'); + + $this->assertCount(3, $coldViolations); + $this->assertEquals($coldViolations, $warmViolations); + } + + /** @return array */ + private function anonymousClassRuleProjectFiles(): array + { + return [ + 'src/Handler.php' => <<<'PHP' + <<<'PHP' + <<<'PHP' + makeTempProject($this->anonymousClassRuleProjectFiles()); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('anonymous_classes.no_parentheses', new AnonymousClassMayNotHaveEmptyParenthesesRule('Source')) + ->skip(['anonymous_classes.no_parentheses' => ['src/Skipped/']]); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $analyserOptions) { + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, $analyserOptions) + ->forRule('anonymous_classes.no_parentheses'); + + $messages = array_map( + static fn(RuleViolation $ruleViolation): string => $ruleViolation->message, + $violations + ); + sort($messages); + + $this->assertSame([ + 'Anonymous class in [App\\Handler] may not have empty parentheses after `class`', + 'Anonymous class in [App\\make] may not have empty parentheses after `class`', + ], $messages); + + foreach ($violations as $violation) { + $this->assertSame('anonymous_classes.no_parentheses', $violation->ruleKey); + $this->assertSame('Source', $violation->layer); + $this->assertTrue($violation->fixable); + $this->assertStringNotContainsString('/Skipped/', $this->normalisePath($violation->file)); + } + } + } + + public function testAnonymousClassNodesResolveRecursiveParents(): void + { + $basePath = $this->makeTempProject([ + 'src/Contract.php' => ' ' ' <<<'PHP' + extendsClass('App\\RootHandler') + && $anonymousClassNode->implementsInterface('App\\Contract') + ) { + return null; + } + + return new RuleViolation( + message: sprintf( + 'Anonymous class in [%s] must implement [App\\Contract]', + $anonymousClassNode->enclosingScopeName() + ), + file: $anonymousClassNode->file, + line: $anonymousClassNode->line, + className: $anonymousClassNode->enclosingScopeName(), + layer: $anonymousClassNode->layer, + ); + } + }; + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('anonymous_classes.contract', $rule); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $analyserOptions) { + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, $analyserOptions) + ->forRule('anonymous_classes.contract'); + + // Only the anonymous class with no parent chain is flagged: the one + // extending BaseHandler reaches RootHandler and Contract transitively. + $this->assertCount(1, $violations); + $this->assertSame(5, $violations[0]->line); + $this->assertSame( + 'Anonymous class in [App\\Factory] must implement [App\\Contract]', + $violations[0]->message + ); + } + } + + public function testAnonymousClassRuleViolationsSurviveTheAnalysisNodeCache(): void + { + $basePath = $this->makeTempProject($this->anonymousClassRuleProjectFiles()); + $analysisResultCache = new AnalysisResultCache($basePath, new FileHashProvider(), 'cache'); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('anonymous_classes.no_parentheses', new AnonymousClassMayNotHaveEmptyParenthesesRule('Source')); + + $coldViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('anonymous_classes.no_parentheses'); + $warmViolations = (new Analyser($basePath, $analysisResultCache, 'config')) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('anonymous_classes.no_parentheses'); + + $this->assertCount(3, $coldViolations); + $this->assertEquals($coldViolations, $warmViolations); + } + + public function testSkippedFunctionRuleIsNotEvaluated(): void + { + $basePath = $this->makeTempProject($this->functionRuleProjectFiles()); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('functions.no_superglobals', $this->makeNoSuperglobalsInFunctionsRule()) + ->skipRule('functions.no_superglobals'); + + $ruleViolationCollection = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()); + + $this->assertFalse($ruleViolationCollection->hasViolations()); + } + public function testBuiltInPsr1RulesDoNotRediscoverFilesAfterExtraction(): void { $basePath = $this->makeTempProject([ @@ -164,6 +559,48 @@ public function testAnalyserDetectsViolationsInBadCode(): void $this->assertTrue($ruleViolationCollection->hasViolations()); } + public function testDddPresetRejectsDoctrineEntityRepositoryInheritanceOnlyInDomain(): void + { + $basePath = $this->makeTempProject([ + 'src/Domain/Order/OrderStore.php' => <<<'PHP' + <<<'PHP' + analyse( + Architecture::define()->withPreset(Preset::DDD()), + analyserOptions: AnalyserOptions::sequential(), + ) + ->forRule(DddPreset::DOMAIN_MUST_NOT_EXTEND_DOCTRINE_ENTITY_REPOSITORY); + + $this->assertCount(1, $violations); + $this->assertSame('App\\Domain\\Order\\OrderStore', $violations[0]->className); + $this->assertSame( + 'Class [App\\Domain\\Order\\OrderStore] must not extend class [Doctrine\\ORM\\EntityRepository]', + $violations[0]->message + ); + } + public function testAnalyserCollectsClassNodesWithSequentialRunner(): void { $basePath = $this->makeTempProject([ @@ -1894,13 +2331,13 @@ public function testPsr4Psr1AndPsr12PreserveInheritedSourceScopesRegardlessOfPre 'composer.json' => '{"autoload":{"psr-4":{"Psr4\\\\":"psr4/",' . '"Psr1\\\\":"psr1/","Psr12\\\\":"psr12/"}}}', 'psr4/Invalid.php' => ' ' ' ' ' 'forRule(Psr12Preset::METHODS_MUST_DECLARE_VISIBILITY); $this->assertCount(1, $psr12Violations); $this->assertStringEndsWith('/psr12/Invalid.php', $this->normalisePath($psr12Violations[0]->file)); + + $keywordConstantViolations = $result->forRule( + Psr12Preset::FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS + ); + $this->assertCount(1, $keywordConstantViolations); + $this->assertStringEndsWith( + '/psr12/Invalid.php', + $this->normalisePath($keywordConstantViolations[0]->file) + ); } } @@ -3395,6 +3841,42 @@ public function __construct(private QueryBuilder $db) {} ); } + public function testAnalyserRulesetSkipsGloballySkippedFileFromPreResolvedList(): void + { + $basePath = $this->makeTempProject([ + 'src/HTTP/Request.php' => <<<'PHP' + layerPattern('HTTP', '/^App\\\\HTTP\\\\.*$/') + ->layerPattern('Database', '/^App\\\\Database\\\\.*$/') + ->skip(['src/HTTP/']) + ->ruleset([ + 'HTTP' => [], // Database NOT allowed + ]); + + // A pre-resolved file list bypasses filesForAnalysis(), so the ruleset + // loop itself must honour the global skip paths. + $ruleViolationCollection = (new Analyser($basePath))->analyse( + $architecture, + ['src/'], + files: [$basePath . '/src/HTTP/Request.php'] + ); + + $this->assertFalse($ruleViolationCollection->hasViolations()); + } + #[DataProvider('rulesetClassLikeKindProvider')] public function testAnalyserRulesetViolationMessageNamesTheClassLikeKind(string $expectedKind, string $source): void { diff --git a/tests/Analyser/ClassCollectorTest.php b/tests/Analyser/AnalysisNodeCollectorTest.php similarity index 74% rename from tests/Analyser/ClassCollectorTest.php rename to tests/Analyser/AnalysisNodeCollectorTest.php index 636219d0..a36c0fb4 100644 --- a/tests/Analyser/ClassCollectorTest.php +++ b/tests/Analyser/AnalysisNodeCollectorTest.php @@ -4,14 +4,23 @@ namespace Boundwize\StructArmed\Tests\Analyser; +use Boundwize\StructArmed\Analyser\AnalysisNodeCollector; use Boundwize\StructArmed\Analyser\AnonymousClassNode; -use Boundwize\StructArmed\Analyser\ClassCollector; use Boundwize\StructArmed\Analyser\ClassLikeAnalysis; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\EnumCaseNode; use Boundwize\StructArmed\LayerResolver\Resolvers\NamespaceLayerResolver; +use PhpParser\Modifiers; +use PhpParser\Node\Const_; +use PhpParser\Node\Name; +use PhpParser\Node\PropertyItem; +use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassConst; use PhpParser\Node\Stmt\ClassMethod; +use PhpParser\Node\Stmt\EnumCase; +use PhpParser\Node\Stmt\Property; +use PhpParser\Node\Stmt\TraitUse; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use PhpParser\ParserFactory; @@ -22,13 +31,98 @@ use function array_column; #[CoversClass(AnonymousClassNode::class)] -#[CoversClass(ClassCollector::class)] +#[CoversClass(AnalysisNodeCollector::class)] #[CoversClass(ClassLikeAnalysis::class)] #[CoversClass(EnumCaseNode::class)] -final class ClassCollectorTest extends TestCase +final class AnalysisNodeCollectorTest extends TestCase { private const BASE_PATH = '/structarmed-test-project'; + public function testCollectsNonCanonicalKeywordConstantSpellings(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' +assertSame( + [[3, 'TRUE'], [4, '\\NULL'], [11, 'False'], [11, '\\tRuE']], + $analysisNodeCollector->getNonCanonicalKeywordConstants() + ); + } + + public function testDistinguishesUnqualifiedFromFullyQualifiedKeywordConstantInsideNamespace(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' +assertSame([[5, 'NULL'], [6, '\\NULL']], $analysisNodeCollector->getNonCanonicalKeywordConstants()); + } + + public function testIgnoresRelativeKeywordConstantSpellingOutsideNamespace(): void + { + $analysisNodeCollector = $this->makeCollector('assertSame([], $analysisNodeCollector->getNonCanonicalKeywordConstants()); + } + + public function testResetsKeywordConstantSpellingsPerFile(): void + { + $analysisNodeCollector = $this->makeCollector('assertSame([[1, 'TRUE']], $analysisNodeCollector->getNonCanonicalKeywordConstants()); + + $analysisNodeCollector->setCurrentFile('/fake/path/Bar.php'); + + $this->assertSame([], $analysisNodeCollector->getNonCanonicalKeywordConstants()); + } + + public function testCollectsNumericLiteralSpellingsAndValues(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' +assertSame( + [ + [3, '10000', 10000], + [4, '10_000', 10000], + [5, '0xFFFFFF', 16777215], + [6, '1e10', 10000000000.0], + [7, '10000.5', 10000.5], + ], + $analysisNodeCollector->getNumericLiterals(), + ); + + $analysisNodeCollector->setCurrentFile('/fake/path/Bar.php'); + + $this->assertSame([], $analysisNodeCollector->getNumericLiterals()); + } + private function collect(string $code): ClassNode { $nodes = $this->collectNodes($code); @@ -40,7 +134,7 @@ private function collect(string $code): ClassNode /** @return ClassNode[] */ private function collectNodes(string $code): array { - return $this->makeCollector($code)->getNodes(); + return $this->makeCollector($code)->getClassNodes(); } /** @return list */ @@ -49,19 +143,19 @@ private function collectAnonymousClassNodes(string $code): array return $this->makeCollector($code)->getAnonymousClassNodes(); } - private function makeCollector(string $code): ClassCollector + private function makeCollector(string $code): AnalysisNodeCollector { $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); - $classCollector = new ClassCollector($namespaceLayerResolver); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); $parser = (new ParserFactory())->createForNewestSupportedVersion(); $ast = $parser->parse($code); - $classCollector->setCurrentFile('/fake/path/Foo.php'); + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php', $parser->getTokens()); - $nodeTraverser = new NodeTraverser(new NameResolver(), $classCollector); + $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); $nodeTraverser->traverse($ast ?? []); - return $classCollector; + return $analysisNodeCollector; } public function testCollectsFileReferencesFromProceduralCode(): void @@ -70,11 +164,11 @@ public function testCollectsFileReferencesFromProceduralCode(): void . 'function handle(Contract $contract): void {}' . "\n" . 'function check(object $value): bool { return $value instanceof Contract; }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Contract']], - $classCollector->getFileReferences() + $analysisNodeCollector->getFileReferences() ); } @@ -84,11 +178,11 @@ public function testDoesNotCollectFileReferencesFromClassBodies(): void . 'final class Checker { public function check(object $value): bool' . ' { return $value instanceof Contract; } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // References inside a named class-like land on its ClassNode // dependencies, not in the file-level references. - $this->assertSame([], $classCollector->getFileReferences()); + $this->assertSame([], $analysisNodeCollector->getFileReferences()); } public function testCollectsClassNameShapedStringValuesAsFileReferences(): void @@ -97,11 +191,11 @@ public function testCollectsClassNameShapedStringValuesAsFileReferences(): void . 'final class Checker { public function check(object $obj): bool {' . ' $contract = \'App\\Contract\'; return $obj instanceof $contract; } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Contract']], - $classCollector->getFileReferences() + $analysisNodeCollector->getFileReferences() ); } @@ -110,13 +204,13 @@ public function testCollectsLeadingBackslashStringValuesAsNormalizedFileReferenc $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // '\App\Contract' is a valid fully-qualified spelling; the stored // name drops the leading separator so it matches ClassNode::$className. $this->assertSame( ['/fake/path/Foo.php' => ['App\Contract']], - $classCollector->getFileReferences() + $analysisNodeCollector->getFileReferences() ); } @@ -125,11 +219,11 @@ public function testCollectsLeadingBackslashStringInstantiationsNormalized(): vo $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Service']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -139,13 +233,13 @@ public function testResolvesConcatenatedConstantClassExpressionAsInstantiation() . 'final class Factory { public function make(): object { return new (\'App\\Service\' . 1)(); } }' . "\n" . 'final class Other { public function make(): object { return new (1 + 1)(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // 'App\Service1' is a class-shaped string; `1 + 1` is not a constant // class expression the collector evaluates at all. $this->assertSame( ['/fake/path/Foo.php' => ['App\Service1']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -155,9 +249,9 @@ public function testDoesNotCollectNonClassNameShapedStringValues(): void . 'final class Greeter { public function greet(): string {' . ' $mode = true ? \'foo-bar\' : \'hello world\'; return $mode . \'123abc\' . \'\'; } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); - $this->assertSame([], $classCollector->getFileReferences()); + $this->assertSame([], $analysisNodeCollector->getFileReferences()); } public function testCollectsInstantiations(): void @@ -165,13 +259,13 @@ public function testCollectsInstantiations(): void $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Service']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); - $this->assertSame([], $classCollector->getFileReferences()); + $this->assertSame([], $analysisNodeCollector->getFileReferences()); } public function testResolvesSelfStaticAndParentInstantiations(): void @@ -183,7 +277,7 @@ public function testResolvesSelfStaticAndParentInstantiations(): void . ' public function three(): BaseRepository { return new parent(); }' . "\n" . '}'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // `static` is late-bound, so it is recorded as a marker the analyser // resolves to Repository and its descendants. @@ -191,24 +285,27 @@ public function testResolvesSelfStaticAndParentInstantiations(): void [ '/fake/path/Foo.php' => [ 'App\Repository', - ClassCollector::deferredInstantiationMarker('static', 'App\Repository'), + AnalysisNodeCollector::deferredInstantiationMarker('static', 'App\Repository'), 'App\BaseRepository', ], ], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } public function testDeferredInstantiationMarkerRoundTrips(): void { foreach (['self', 'static', 'parent'] as $keyword) { - $marker = ClassCollector::deferredInstantiationMarker($keyword, 'App\\Factory'); + $marker = AnalysisNodeCollector::deferredInstantiationMarker($keyword, 'App\\Factory'); - $this->assertSame([$keyword, 'App\\Factory'], ClassCollector::parseDeferredInstantiationMarker($marker)); + $this->assertSame( + [$keyword, 'App\\Factory'], + AnalysisNodeCollector::parseDeferredInstantiationMarker($marker) + ); } - $this->assertNull(ClassCollector::parseDeferredInstantiationMarker('App\\Factory')); - $this->assertNull(ClassCollector::parseDeferredInstantiationMarker('other@App\\Factory')); + $this->assertNull(AnalysisNodeCollector::parseDeferredInstantiationMarker('App\\Factory')); + $this->assertNull(AnalysisNodeCollector::parseDeferredInstantiationMarker('other@App\\Factory')); } public function testDoesNotRecordStringWithMarkerSeparatorAsInstantiation(): void @@ -216,11 +313,11 @@ public function testDoesNotRecordStringWithMarkerSeparatorAsInstantiation(): voi $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // `@` never occurs in a class name, and only self/static/parent form // a marker: anything else records nothing. - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testRecordsTraitSelfStaticAndParentInstantiationsAsMarkers(): void @@ -235,7 +332,7 @@ public function testRecordsTraitSelfStaticAndParentInstantiationsAsMarkers(): vo . ' public static function staticViaClassConstant(): object { return new (static::class)(); }' . "\n" . '}'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // A trait is never instantiated itself: each marker is resolved by the // analyser against every class using the trait. `new (X::class)()` @@ -243,12 +340,12 @@ public function testRecordsTraitSelfStaticAndParentInstantiationsAsMarkers(): vo $this->assertSame( [ '/fake/path/Foo.php' => [ - ClassCollector::deferredInstantiationMarker('parent', 'App\Factory'), - ClassCollector::deferredInstantiationMarker('self', 'App\Factory'), - ClassCollector::deferredInstantiationMarker('static', 'App\Factory'), + AnalysisNodeCollector::deferredInstantiationMarker('parent', 'App\Factory'), + AnalysisNodeCollector::deferredInstantiationMarker('self', 'App\Factory'), + AnalysisNodeCollector::deferredInstantiationMarker('static', 'App\Factory'), ], ], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -261,7 +358,7 @@ public function testRecordsClassSelfAsNameAndStaticAsMarker(): void . ' public static function createParent(): object { return new parent(); }' . "\n" . '}'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // `self` and `parent` are lexically bound; `static` is late-bound, so // its marker lets the analyser include the descendants of Model. @@ -269,11 +366,11 @@ public function testRecordsClassSelfAsNameAndStaticAsMarker(): void [ '/fake/path/Foo.php' => [ 'App\Model', - ClassCollector::deferredInstantiationMarker('static', 'App\Model'), + AnalysisNodeCollector::deferredInstantiationMarker('static', 'App\Model'), 'App\Base', ], ], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -297,7 +394,7 @@ public function testResolvesParentInsideAnonymousClassAgainstTheAnonymousClass() . ' public function own(): object { return new parent(); }' . "\n" . '}'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // `parent` belongs to the innermost class-like: the anonymous class, // not the enclosing trait or class. An anonymous class without a @@ -306,11 +403,11 @@ public function testResolvesParentInsideAnonymousClassAgainstTheAnonymousClass() [ '/fake/path/Foo.php' => [ 'App\Base', - ClassCollector::deferredInstantiationMarker('parent', 'App\Factory'), + AnalysisNodeCollector::deferredInstantiationMarker('parent', 'App\Factory'), 'App\Other', ], ], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -325,11 +422,11 @@ public function testDoesNotRecordParentAccessWithoutNewAsInstantiation(): void . '}' . "\n" . 'class Host extends Base { use Factory; public function __construct() { parent::__construct(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // Only `new` instantiates: static calls, constants, ::class, and // static properties on parent never mark it (or a trait marker). - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testResolvesConstantClassExpressionInstantiations(): void @@ -341,11 +438,11 @@ public function testResolvesConstantClassExpressionInstantiations(): void . ' public function fromConcat(): object { return new (\'App\\\\\' . \'Joined\')(); }' . "\n" . '}'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Base', 'App\StringBase', 'App\Joined']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -354,11 +451,11 @@ public function testResolvesSelfClassConstantInstantiation(): void $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Registry']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -371,9 +468,9 @@ public function testIgnoresRuntimeFedDynamicInstantiations(): void . 'final class Holder { public function __construct(private string $class) {}' . ' public function make(): object { return new ($this->class)(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testResolvesChainedReflectionConstruction(): void @@ -382,13 +479,13 @@ public function testResolvesChainedReflectionConstruction(): void . 'final class Booter { public function boot(): object {' . ' return (new \ReflectionClass(Base::class))->newInstance(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // ReflectionClass itself is instantiated, and so is the class it // reflects. $this->assertSame( ['/fake/path/Foo.php' => ['ReflectionClass', 'App\Base']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -398,11 +495,11 @@ public function testResolvesNullsafeChainedReflectionConstruction(): void . 'final class Booter { public function boot(): ?object {' . ' return (new \\ReflectionClass(\'App\\Child\'))?->newInstanceWithoutConstructor(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['ReflectionClass', 'App\Child']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -414,11 +511,11 @@ public function testIgnoresReflectionConstructionWithUnresolvableTarget(): void . 'final class Booter { public function boot(\\ReflectionClass $r, string $name): object {' . ' $other = new \\ReflectionClass($name); return $r->newInstance() ?? $other->newInstance(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['ReflectionClass']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -432,11 +529,11 @@ public function testIgnoresNonReflectionChainedConstructionCalls(): void . ' $a = (new Container())->newInstance(); $b = (new ($x::class))->newInstance();' . ' $c = (new \\ReflectionClass())->newInstance(); return $a ?? $b ?? $c; } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); $this->assertSame( ['/fake/path/Foo.php' => ['App\Container', 'ReflectionClass']], - $classCollector->getFileInstantiations() + $analysisNodeCollector->getFileInstantiations() ); } @@ -446,9 +543,9 @@ public function testIgnoresOrdinaryMethodCalls(): void . 'final class Caller { public function run(object $service): mixed {' . ' return $service->handle(); } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testIgnoresUnresolvableClassNameExpressions(): void @@ -460,9 +557,9 @@ public function testIgnoresUnresolvableClassNameExpressions(): void . ' $a = new (\'App\\\\\' . $suffix)(); $b = new ($obj::class)();' . ' $c = new (\'not a class name!\')(); return $a ?? $b ?? $c; } }'; - $classCollector = $this->makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testDoesNotCollectAnonymousInstantiations(): void @@ -470,20 +567,20 @@ public function testDoesNotCollectAnonymousInstantiations(): void $code = 'makeCollector($code); + $analysisNodeCollector = $this->makeCollector($code); // Anonymous classes are tracked as AnonymousClassNodes, and their // known declaration does not make any named class instantiable. - $this->assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testIgnoresRelativeInstantiationOutsideClassScope(): void { // `new self` outside a class parses but cannot be resolved to a name; // PHP itself rejects it at runtime. - $classCollector = $this->makeCollector('makeCollector('assertSame([], $classCollector->getFileInstantiations()); + $this->assertSame([], $analysisNodeCollector->getFileInstantiations()); } public function testCollectsFinalClass(): void @@ -566,6 +663,62 @@ public function make(): BaseHandler { return new class extends BaseHandler {}; } $this->assertSame('/fake/path/Foo.php', $anonymousClassNodes[0]->file); } + public function testCollectsAnonymousClassEnclosingScopesAndEmptyParentheses(): void + { + $anonymousClassNodes = $this->collectAnonymousClassNodes(<<<'PHP' + assertCount(4, $anonymousClassNodes); + + $this->assertSame('App\HandlerFactory', $anonymousClassNodes[0]->enclosingClassName); + $this->assertNull($anonymousClassNodes[0]->enclosingFunctionName); + $this->assertSame('App\HandlerFactory', $anonymousClassNodes[0]->enclosingScopeName()); + $this->assertTrue($anonymousClassNodes[0]->hasEmptyParentheses); + + $this->assertNull($anonymousClassNodes[1]->enclosingClassName); + $this->assertSame('App\make', $anonymousClassNodes[1]->enclosingFunctionName); + $this->assertSame('App\make', $anonymousClassNodes[1]->enclosingScopeName()); + $this->assertTrue($anonymousClassNodes[1]->hasEmptyParentheses); + $this->assertSame(['Stringable'], $anonymousClassNodes[1]->implements); + + $this->assertSame(AnonymousClassNode::FILE_SCOPE, $anonymousClassNodes[2]->enclosingScopeName()); + $this->assertFalse($anonymousClassNodes[2]->hasEmptyParentheses); + + $this->assertSame(AnonymousClassNode::FILE_SCOPE, $anonymousClassNodes[3]->enclosingScopeName()); + $this->assertFalse($anonymousClassNodes[3]->hasEmptyParentheses); + + // The fake file is outside every configured layer path. + $this->assertNull($anonymousClassNodes[0]->layer); + $this->assertSame([], $anonymousClassNodes[0]->layers); + $this->assertFalse($anonymousClassNodes[0]->isInLayer('Domain')); + } + + public function testAnonymousClassParenthesesAreUnknownWithoutTokens(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); + (new NodeTraverser(new NameResolver(), $analysisNodeCollector)) + ->traverse($parser->parse('getAnonymousClassNodes(); + + $this->assertCount(1, $anonymousClassNodes); + $this->assertFalse($anonymousClassNodes[0]->hasEmptyParentheses); + } + public function testCollectsTopLevelAnonymousClassNodeInFileWithoutNamedClasses(): void { $anonymousClassNodes = $this->collectAnonymousClassNodes('assertSame('App\BaseHandler', $anonymousClassNodes[0]->extends); } + public function testCollectsAnonymousClassMembersAndBodyFactsLikeANamedClass(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' + getAnonymousClassNodes(); + $classNodes = $analysisNodeCollector->getClassNodes(); + + $this->assertCount(1, $anonymousClassNodes); + $anonymousClassNode = $anonymousClassNodes[0]; + + $this->assertTrue($anonymousClassNode->isReadonly); + $this->assertSame(['App\Helper'], $anonymousClassNode->traits); + $this->assertSame(['LIMIT'], array_column($anonymousClassNode->constants, 'name')); + $this->assertSame(['count', 'clock'], array_column($anonymousClassNode->properties, 'name')); + $this->assertSame(['__construct', '__toString'], array_column($anonymousClassNode->methods, 'name')); + $this->assertSame(1, $anonymousClassNode->constructorParamCount()); + $this->assertSame(['strtoupper'], $anonymousClassNode->functionCalls); + $this->assertSame(['$_GET'], $anonymousClassNode->superglobals); + $this->assertSame(['isset', 'exit'], $anonymousClassNode->languageConstructs); + $this->assertTrue($anonymousClassNode->dependsOn('Stringable')); + $this->assertTrue($anonymousClassNode->dependsOn('App\Helper')); + $this->assertTrue($anonymousClassNode->dependsOn('App\Support\Clock')); + $this->assertTrue($anonymousClassNode->dependsOn('App\Other')); + // The file's imports belong to the file, not to the anonymous class. + $this->assertFalse($anonymousClassNode->dependsOn('App\Support\Unused')); + + // The enclosing class keeps seeing the anonymous class body, as it + // sees a closure's, but the members belong to the anonymous class. + $this->assertCount(1, $classNodes); + $this->assertSame(['make'], array_column($classNodes[0]->methods, 'name')); + $this->assertSame([], $classNodes[0]->constants); + $this->assertSame([], $classNodes[0]->properties); + $this->assertSame([], $classNodes[0]->traits); + $this->assertTrue($classNodes[0]->dependsOn('App\Other')); + $this->assertTrue($classNodes[0]->dependsOn('App\Support\Unused')); + $this->assertSame(['strtoupper'], $classNodes[0]->functionCalls); + $this->assertSame(['$_GET'], $classNodes[0]->superglobals); + $this->assertSame(['isset', 'exit'], $classNodes[0]->languageConstructs); + } + + public function testTopLevelAnonymousClassBodyReferencesAreItsOwnDependencies(): void + { + $analysisNodeCollector = $this->makeCollector(<<<'PHP' + getAnonymousClassNodes(); + + $this->assertCount(1, $anonymousClassNodes); + $this->assertSame(['App\BaseHandler', 'App\Other'], $anonymousClassNodes[0]->dependencies); + // Resolved once the whole file is traversed, like a named class's. + $this->assertSame(['App\helper'], $anonymousClassNodes[0]->functionCalls); + $this->assertSame([], $analysisNodeCollector->getFileReferences()); + } + + public function testNestedAnonymousClassesKeepTheirOwnMembersAndShareBodyDependencies(): void + { + $anonymousClassNodes = $this->collectAnonymousClassNodes(<<<'PHP' + assertCount(2, $anonymousClassNodes); + [$inner, $outer] = $anonymousClassNodes; + + $this->assertSame(['inner'], array_column($inner->methods, 'name')); + $this->assertSame(['outer'], array_column($outer->methods, 'name')); + $this->assertNull($outer->enclosingClassName); + $this->assertNull($inner->enclosingClassName); + + // The inner body is counted on both, like a closure's on its enclosing scopes. + $this->assertSame(['App\Clock'], $inner->dependencies); + $this->assertSame(['App\Clock'], $outer->dependencies); + } + public function testCollectsAnonymousClassNodeWithoutExtends(): void { $anonymousClassNodes = $this->collectAnonymousClassNodes('assertFalse($classNode->methods[1]->isMagic); } - public function testFiltersClassMethodsOncePerClassLike(): void + /** + * Node dispatch is keyed by exact node class, as the parser never + * subclasses its nodes, so a hand-built Class_ (not a subclass of it) is + * traversed like a parsed one. + */ + public function testCollectsEachClassMethodOnce(): void { $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); - $classCollector = new ClassCollector($namespaceLayerResolver); - $classLike = new class ('Foo', [ + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $class = new Class_('Foo', [ 'stmts' => [new ClassMethod('__construct'), new ClassMethod('bar')], - ]) extends Class_ { - public int $getMethodsCallCount = 0; + ]); - public function getMethods(): array - { - ++$this->getMethodsCallCount; + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); - return parent::getMethods(); - } - }; - - $classCollector->setCurrentFile('/fake/path/Foo.php'); - - (new NodeTraverser(new NameResolver(), $classCollector))->traverse([$classLike]); + (new NodeTraverser(new NameResolver(), $analysisNodeCollector))->traverse([$class]); - $this->assertSame(1, $classLike->getMethodsCallCount); $this->assertSame( ['__construct', 'bar'], - array_column($classCollector->getNodes()[0]->methods, 'name'), + array_column($analysisNodeCollector->getClassNodes()[0]->methods, 'name'), ); } @@ -768,6 +1023,46 @@ public function label(): string $this->assertSame(['label'], array_column($classNode->methods, 'name')); } + public function testResolvesImportedClassNameInEnumCaseValue(): void + { + $classNode = $this->collect(<<<'PHP' + assertSame(['Vendor\Foo', 'App\Type'], array_column($classNode->enumCases, 'value')); + } + + public function testIgnoresEnumCaseDeclaredInAnonymousClass(): void + { + // php-parser accepts a case in a class body; only PHP's compiler + // rejects it, so the collector must not attribute it to any class. + $classNode = $this->collect(<<<'PHP' + assertSame(['One'], array_column($classNode->enumCases, 'name')); + } + public function testCollectsIntBackedEnumCaseValues(): void { $classNode = $this->collect( @@ -1790,14 +2085,32 @@ public function bar(): void { public function testIgnoresClassMethodNodesOutsideTrackedClassLike(): void { $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); - $classCollector = new ClassCollector($namespaceLayerResolver); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); $classMethod = new ClassMethod('orphan'); - $classCollector->setCurrentFile('/fake/path/Foo.php'); + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); + + $analysisNodeCollector->enterNode($classMethod); + $analysisNodeCollector->leaveNode($classMethod); + + $this->assertSame([], $analysisNodeCollector->getClassNodes()); + } + + public function testIgnoresMemberNodesOutsideTrackedClassLike(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $enumCase = new EnumCase('Orphan'); + + $analysisNodeCollector->setCurrentFile('/fake/path/Foo.php'); - $classCollector->enterNode($classMethod); - $classCollector->leaveNode($classMethod); + $analysisNodeCollector->enterNode(new Property(Modifiers::PUBLIC, [new PropertyItem('orphan')])); + $analysisNodeCollector->enterNode(new ClassConst([new Const_('ORPHAN', new Int_(1))])); + $analysisNodeCollector->enterNode(new TraitUse([new Name('OrphanTrait')])); + $analysisNodeCollector->enterNode($enumCase); + $analysisNodeCollector->leaveNode($enumCase); - $this->assertSame([], $classCollector->getNodes()); + $this->assertSame([], $analysisNodeCollector->getClassNodes()); + $this->assertSame([], $analysisNodeCollector->getAnonymousClassNodes()); } } diff --git a/tests/Analyser/ClassNodeExtractorTest.php b/tests/Analyser/AnalysisNodeExtractorTest.php similarity index 65% rename from tests/Analyser/ClassNodeExtractorTest.php rename to tests/Analyser/AnalysisNodeExtractorTest.php index 7acc3376..b8b9aff3 100644 --- a/tests/Analyser/ClassNodeExtractorTest.php +++ b/tests/Analyser/AnalysisNodeExtractorTest.php @@ -4,8 +4,8 @@ namespace Boundwize\StructArmed\Tests\Analyser; +use Boundwize\StructArmed\Analyser\AnalysisNodeExtractor; use Boundwize\StructArmed\Analyser\ClassNode; -use Boundwize\StructArmed\Analyser\ClassNodeExtractor; use Boundwize\StructArmed\Analyser\ExtractionResult; use Boundwize\StructArmed\LayerResolver\Resolvers\NamespaceLayerResolver; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; @@ -15,18 +15,18 @@ use function file_put_contents; -#[CoversClass(ClassNodeExtractor::class)] +#[CoversClass(AnalysisNodeExtractor::class)] #[CoversClass(ExtractionResult::class)] -final class ClassNodeExtractorTest extends TestCase +final class AnalysisNodeExtractorTest extends TestCase { use TemporaryDirectoryCleanupTrait; public function testExtractReturnsEmptyArrayForNoFiles(): void { $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'App\\Domain'], '/tmp'); - $classNodeExtractor = new ClassNodeExtractor($namespaceLayerResolver); + $analysisNodeExtractor = new AnalysisNodeExtractor($namespaceLayerResolver); - $extractionResult = $classNodeExtractor->extract([]); + $extractionResult = $analysisNodeExtractor->extract([]); $this->assertSame([], $extractionResult->classNodes); $this->assertSame([], $extractionResult->fileAnalyses); @@ -48,15 +48,48 @@ final class Foo PHP); $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'App\\Domain'], $dir); - $classNodeExtractor = new ClassNodeExtractor($namespaceLayerResolver); + $analysisNodeExtractor = new AnalysisNodeExtractor($namespaceLayerResolver); - $extractionResult = $classNodeExtractor->extract([$file]); + $extractionResult = $analysisNodeExtractor->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); $this->assertInstanceOf(ClassNode::class, $extractionResult->classNodes[0]); $this->assertSame('App\\Domain\\Foo', $extractionResult->classNodes[0]->className); } + public function testExtractRecordsKeywordConstantSpellingsInFileAnalysis(): void + { + $dir = $this->makeTemporaryDirectory('structarmed-extractor-test'); + $file = $dir . '/Foo.php'; + + file_put_contents($file, " 'App\\Domain'], $dir); + $extractionResult = (new AnalysisNodeExtractor($namespaceLayerResolver))->extract([$file]); + + $this->assertSame( + [[3, 'TRUE'], [3, '\\NULL']], + $extractionResult->fileAnalyses[$file]->nonCanonicalKeywordConstants + ); + $this->assertTrue($extractionResult->fileAnalyses[$file]->hasSideEffects); + } + + public function testExtractRecordsNumericLiteralsInFileAnalysis(): void + { + $dir = $this->makeTemporaryDirectory('structarmed-extractor-test'); + $file = $dir . '/Foo.php'; + + file_put_contents($file, " 'App\\Domain'], $dir); + $extractionResult = (new AnalysisNodeExtractor($namespaceLayerResolver))->extract([$file]); + + $this->assertSame( + [[3, '10000', 10000], [3, '10_000', 10000], [3, '1e10', 10000000000.0]], + $extractionResult->fileAnalyses[$file]->numericLiterals, + ); + } + public function testExtractSkipsFilesWithParseErrors(): void { $dir = $this->makeTemporaryDirectory('structarmed-extractor-test'); @@ -65,9 +98,9 @@ public function testExtractSkipsFilesWithParseErrors(): void file_put_contents($file, ' 'App\\Domain'], $dir); - $classNodeExtractor = new ClassNodeExtractor($namespaceLayerResolver); + $analysisNodeExtractor = new AnalysisNodeExtractor($namespaceLayerResolver); - $extractionResult = $classNodeExtractor->extract([$file]); + $extractionResult = $analysisNodeExtractor->extract([$file]); $this->assertSame([], $extractionResult->classNodes); } @@ -80,9 +113,9 @@ public function testExtractSkipsFilesWithEmptyAst(): void file_put_contents($file, ' 'App\\Domain'], $dir); - $classNodeExtractor = new ClassNodeExtractor($namespaceLayerResolver); + $analysisNodeExtractor = new AnalysisNodeExtractor($namespaceLayerResolver); - $extractionResult = $classNodeExtractor->extract([$file]); + $extractionResult = $analysisNodeExtractor->extract([$file]); $this->assertSame([], $extractionResult->classNodes); } @@ -95,7 +128,7 @@ public function testExtractReturnsFactsFromTheSameParse(): void file_put_contents($file, ' ''], $dir); - $extractionResult = (new ClassNodeExtractor($namespaceLayerResolver)) + $extractionResult = (new AnalysisNodeExtractor($namespaceLayerResolver)) ->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); @@ -112,7 +145,7 @@ public function testExtractSkipsFileAnalysisWhenItIsNotRequested(): void file_put_contents($file, ' ''], $dir); - $extractionResult = (new ClassNodeExtractor($namespaceLayerResolver)) + $extractionResult = (new AnalysisNodeExtractor($namespaceLayerResolver)) ->extract([$file], withFileAnalysis: false); $this->assertCount(1, $extractionResult->classNodes); @@ -135,7 +168,7 @@ final class Bar PHP); $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'App\\Domain'], $dir); - $classNodeExtractor = new ClassNodeExtractor($namespaceLayerResolver); + $analysisNodeExtractor = new AnalysisNodeExtractor($namespaceLayerResolver); $advanced = []; @@ -161,7 +194,7 @@ public function finish(): void } }; - $classNodeExtractor->extract([$file], $progressHandler); + $analysisNodeExtractor->extract([$file], $progressHandler); $this->assertCount(1, $advanced); $this->assertSame($file, $advanced[0]); diff --git a/tests/Analyser/AnonymousClassNodeTest.php b/tests/Analyser/AnonymousClassNodeTest.php new file mode 100644 index 00000000..aa1a5ea2 --- /dev/null +++ b/tests/Analyser/AnonymousClassNodeTest.php @@ -0,0 +1,96 @@ +assertSame([], $anonymousClassNode->parentClasses); + $this->assertSame([], $anonymousClassNode->parentInterfaces); + $this->assertTrue($anonymousClassNode->extendsClass('App\\Support\\BaseClass')); + $this->assertFalse($anonymousClassNode->extendsClass('App\\Support\\RootClass')); + $this->assertTrue($anonymousClassNode->implementsInterface('App\\Contracts\\FooInterface')); + $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\RootInterface')); + + $anonymousClassNode->setRecursiveParents( + ['App\\Support\\baseclass', 'App\\Support\\rootclass'], + ['App\\Contracts\\foointerface', 'App\\Contracts\\rootinterface'], + ); + + $this->assertSame(['App\\Support\\baseclass', 'App\\Support\\rootclass'], $anonymousClassNode->parentClasses); + $this->assertTrue($anonymousClassNode->extendsClass('App\\Support\\RootClass')); + $this->assertFalse($anonymousClassNode->extendsClass('App\\Support\\OtherClass')); + $this->assertTrue($anonymousClassNode->implementsInterface('App\\Contracts\\RootInterface')); + $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\OtherInterface')); + } + + public function testExtendsClassWithoutParentIsAlwaysFalse(): void + { + $anonymousClassNode = new AnonymousClassNode(file: '/src/helpers.php', line: 3, extends: null); + + $this->assertFalse($anonymousClassNode->extendsClass('App\\Support\\BaseClass')); + $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\FooInterface')); + } + + public function testCarriesMembersAndBodyFactsLikeAClassNode(): void + { + $anonymousClassNode = new AnonymousClassNode( + file: '/src/HandlerFactory.php', + line: 7, + extends: null, + layer: 'Source', + isReadonly: true, + dependencies: ['App\\Support\\Clock'], + methods: [new MethodNode('__construct', 'public', false, false, 2, 1, 3)], + constants: [new ConstantNode('LIMIT')], + properties: [new PropertyNode('clock', 'private', true)], + functionCalls: ['strlen'], + superglobals: ['$_GET'], + languageConstructs: ['die'], + ); + + $this->assertTrue($anonymousClassNode->isReadonly); + $this->assertTrue($anonymousClassNode->isInLayer('Source')); + $this->assertTrue($anonymousClassNode->dependsOn('App\\Support\\Clock')); + $this->assertTrue($anonymousClassNode->dependsOnNamespace('App\\Support')); + $this->assertTrue($anonymousClassNode->callsFunction('STRLEN')); + $this->assertTrue($anonymousClassNode->accessesSuperglobals()); + $this->assertTrue($anonymousClassNode->usesLanguageConstruct('exit')); + $this->assertSame(2, $anonymousClassNode->constructorParamCount()); + $this->assertSame('LIMIT', $anonymousClassNode->constants[0]->name); + $this->assertSame('clock', $anonymousClassNode->properties[0]->name); + } + + public function testMembersAndBodyFactsDefaultToEmpty(): void + { + $anonymousClassNode = new AnonymousClassNode(file: '/src/helpers.php', line: 3, extends: null); + + $this->assertFalse($anonymousClassNode->isReadonly); + $this->assertSame([], $anonymousClassNode->methods); + $this->assertSame([], $anonymousClassNode->constants); + $this->assertSame([], $anonymousClassNode->properties); + $this->assertSame(0, $anonymousClassNode->constructorParamCount()); + $this->assertFalse($anonymousClassNode->dependsOn('App\\Support\\Clock')); + $this->assertFalse($anonymousClassNode->callsFunction('strlen')); + $this->assertFalse($anonymousClassNode->accessesSuperglobals()); + $this->assertFalse($anonymousClassNode->usesLanguageConstruct('exit')); + } +} diff --git a/tests/Analyser/AnonymousFunctionNodeTest.php b/tests/Analyser/AnonymousFunctionNodeTest.php new file mode 100644 index 00000000..52eef685 --- /dev/null +++ b/tests/Analyser/AnonymousFunctionNodeTest.php @@ -0,0 +1,78 @@ +assertSame('Closure', $anonymousFunctionNode->getType()); + $this->assertFalse($anonymousFunctionNode->isArrowFunction); + $this->assertFalse($anonymousFunctionNode->isStatic); + $this->assertSame('file scope', $anonymousFunctionNode->enclosingScopeName()); + $this->assertSame(AnonymousFunctionNode::FILE_SCOPE, $anonymousFunctionNode->enclosingScopeName()); + $this->assertSame(['Support'], $anonymousFunctionNode->layers); + $this->assertTrue($anonymousFunctionNode->isInLayer('Support')); + $this->assertFalse($anonymousFunctionNode->accessesSuperglobals()); + } + + public function testEnclosingClassWinsOverEnclosingFunction(): void + { + $anonymousFunctionNode = new AnonymousFunctionNode( + file: '/src/Handler.php', + line: 9, + layer: null, + isArrowFunction: true, + isStatic: true, + enclosingClassName: 'App\\Handler', + enclosingFunctionName: 'App\\bootstrap', + ); + + $this->assertSame('Arrow function', $anonymousFunctionNode->getType()); + $this->assertSame('App\\Handler', $anonymousFunctionNode->enclosingScopeName()); + $this->assertSame([], $anonymousFunctionNode->layers); + } + + public function testEnclosingFunctionIsUsedWithoutEnclosingClass(): void + { + $anonymousFunctionNode = new AnonymousFunctionNode( + file: '/src/helpers.php', + line: 9, + layer: null, + enclosingFunctionName: 'App\\bootstrap', + ); + + $this->assertSame('App\\bootstrap', $anonymousFunctionNode->enclosingScopeName()); + } + + public function testBodyQueries(): void + { + $anonymousFunctionNode = new AnonymousFunctionNode( + file: '/src/helpers.php', + line: 1, + layer: 'Support', + dependencies: ['App\\View\\Template'], + functionCalls: ['App\\escape'], + superglobals: ['$_POST'], + languageConstructs: ['exit'], + ); + + $this->assertTrue($anonymousFunctionNode->dependsOn('App\\View\\Template')); + $this->assertTrue($anonymousFunctionNode->dependsOnNamespace('App\\View')); + $this->assertFalse($anonymousFunctionNode->dependsOnNamespace('App\\Domain')); + $this->assertTrue($anonymousFunctionNode->callsFunction('app\\escape')); + $this->assertTrue($anonymousFunctionNode->accessesSuperglobals()); + $this->assertTrue($anonymousFunctionNode->usesLanguageConstruct('exit')); + $this->assertTrue($anonymousFunctionNode->usesLanguageConstruct('die')); + $this->assertFalse($anonymousFunctionNode->usesLanguageConstruct('print')); + } +} diff --git a/tests/Analyser/ClassNodeTest.php b/tests/Analyser/ClassNodeTest.php index 2d6e28be..8363469b 100644 --- a/tests/Analyser/ClassNodeTest.php +++ b/tests/Analyser/ClassNodeTest.php @@ -418,43 +418,6 @@ className: 'App\\Domain\\BaseRepository', $this->assertFalse($classNode->isInstantiated); } - public function testSetInstantiatedIsIgnoredForNonInstantiableClassLikes(): void - { - $makeNode = static fn ( - bool $isAbstract = false, - bool $isInterface = false, - bool $isTrait = false, - bool $isEnum = false, - ): ClassNode => new ClassNode( - className: 'App\\Domain\\SomeClassLike', - file: '/src/SomeClassLike.php', - line: 5, - layer: 'Domain', - extends: null, - isAbstract: $isAbstract, - isFinal: false, - isInterface: $isInterface, - isReadonly: false, - isTrait: $isTrait, - isEnum: $isEnum, - ); - - $nonInstantiables = [ - 'abstract class' => $makeNode(isAbstract: true), - 'interface' => $makeNode(isInterface: true), - 'trait' => $makeNode(isTrait: true), - 'enum' => $makeNode(isEnum: true), - ]; - - foreach ($nonInstantiables as $kind => $classNode) { - $classNode->setInstantiated(true); - - // `new` on these class-likes is fatal, so they can never be an - // instantiation target. - $this->assertFalse($classNode->isInstantiated, $kind); - } - } - public function testDependsOnMatchesExistingClassesExactly(): void { $classNode = new ClassNode( diff --git a/tests/Analyser/FileAnalysisProviderTest.php b/tests/Analyser/FileAnalysisProviderTest.php index d23d925f..a2efe6fa 100644 --- a/tests/Analyser/FileAnalysisProviderTest.php +++ b/tests/Analyser/FileAnalysisProviderTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function array_column; use function base64_encode; use function file_put_contents; use function sys_get_temp_dir; @@ -112,6 +113,26 @@ public function testReportsInvalidTagsAndInvalidAstWithoutThrowing(): void $this->assertFalse($fileAnalysis->hasSideEffects); } + public function testExposesTokensOfTheFileParsedLast(): void + { + $fileAnalysisProvider = new FileAnalysisProvider(); + + $this->assertIsArray($fileAnalysisProvider->ast($this->source('tokens() as $token) { + $tokenTexts[] = $token->text; + } + + $this->assertContains('class', $tokenTexts); + $this->assertContains('(', $tokenTexts); + + // The next parse replaces them. + $this->assertIsArray($fileAnalysisProvider->ast($this->source('assertNotContains('class', array_column($fileAnalysisProvider->tokens(), 'text')); + } + public function testParsesAstWithoutRetainingItForAnalysis(): void { $fileAnalysisProvider = new FileAnalysisProvider(); diff --git a/tests/Analyser/FunctionLikeCollectionTest.php b/tests/Analyser/FunctionLikeCollectionTest.php new file mode 100644 index 00000000..2035890b --- /dev/null +++ b/tests/Analyser/FunctionLikeCollectionTest.php @@ -0,0 +1,433 @@ + 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $ast = $parser->parse($code); + + $analysisNodeCollector->setCurrentFile($file); + + $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); + $nodeTraverser->traverse($ast ?? []); + + return $analysisNodeCollector; + } + + private function collectFunction(string $code): FunctionNode + { + $functionNodes = $this->makeCollector($code)->getFunctionNodes(); + $this->assertCount(1, $functionNodes, 'Expected exactly one function node'); + + return $functionNodes[0]; + } + + private function collectAnonymousFunction(string $code): AnonymousFunctionNode + { + $anonymousFunctionNodes = $this->makeCollector($code)->getAnonymousFunctionNodes(); + $this->assertCount(1, $anonymousFunctionNodes, 'Expected exactly one anonymous function node'); + + return $anonymousFunctionNodes[0]; + } + + public function testCollectsNamespacedFunctionWithLayerAndSignature(): void + { + $functionNode = $this->collectFunction( + 'assertSame('App\Domain\format', $functionNode->functionName); + $this->assertSame(self::FILE, $functionNode->file); + $this->assertSame(2, $functionNode->line); + $this->assertSame('Domain', $functionNode->layer); + $this->assertSame(['Domain'], $functionNode->layers); + $this->assertTrue($functionNode->hasReturnType); + $this->assertSame(2, $functionNode->paramCount); + $this->assertSame(1, $functionNode->cyclomaticComplexity); + $this->assertSame(1, $functionNode->lineCount); + } + + public function testCollectsGlobalFunctionOutsideAnyLayer(): void + { + $functionNode = $this->makeCollector( + 'getFunctionNodes()[0] ?? null; + + $this->assertInstanceOf(FunctionNode::class, $functionNode); + $this->assertSame('helper', $functionNode->functionName); + $this->assertNull($functionNode->layer); + $this->assertSame([], $functionNode->layers); + $this->assertFalse($functionNode->hasReturnType); + $this->assertSame(0, $functionNode->paramCount); + $this->assertSame(0, $functionNode->lineCount); + } + + public function testSelectsMostSpecificLayerWhenMultipleLayersMatch(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver( + ['Source' => 'src/', 'Domain' => 'src/Domain/'], + self::BASE_PATH + ); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $ast = $parser->parse('setCurrentFile(self::FILE); + + $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); + $nodeTraverser->traverse($ast ?? []); + + $functionNode = $analysisNodeCollector->getFunctionNodes()[0]; + + $this->assertSame('Domain', $functionNode->layer); + $this->assertSame(['Source', 'Domain'], $functionNode->layers); + } + + public function testCollectsFunctionDependenciesWithoutSeedingNamespaceImports(): void + { + $functionNode = $this->collectFunction( + 'assertSame( + ['App\Infrastructure\Mailer', 'DateTimeImmutable', 'App\Domain\Order'], + $functionNode->dependencies + ); + } + + public function testFunctionReferencesStillCountAsFileReferences(): void + { + $analysisNodeCollector = $this->makeCollector( + 'assertSame([self::FILE => ['App\Domain\Contract']], $analysisNodeCollector->getFileReferences()); + $this->assertSame(['App\Domain\Contract'], $analysisNodeCollector->getFunctionNodes()[0]->dependencies); + } + + public function testCollectsFunctionCallsSuperglobalsAndLanguageConstructs(): void + { + $functionNode = $this->makeCollector( + 'getFunctionNodes()[1]; + + $this->assertSame('App\Domain\handle', $functionNode->functionName); + $this->assertSame(['App\Domain\local', 'strlen'], $functionNode->functionCalls); + $this->assertSame(['$_GET'], $functionNode->superglobals); + $this->assertSame(['echo', 'exit'], $functionNode->languageConstructs); + $this->assertTrue($functionNode->callsFunction('App\Domain\local')); + $this->assertTrue($functionNode->accessesSuperglobals()); + $this->assertTrue($functionNode->usesLanguageConstruct('die')); + } + + public function testResolvesCallToFunctionDeclaredLaterInFile(): void + { + $functionNode = $this->makeCollector( + 'getFunctionNodes()[0]; + + // Function nodes are built after the whole file is traversed, so a + // call to a function declared further down still resolves. + $this->assertSame('App\Domain\caller', $functionNode->functionName); + $this->assertSame(['App\Domain\callee'], $functionNode->functionCalls); + } + + public function testCalculatesFunctionCyclomaticComplexityAndLineCount(): void + { + $functionNode = $this->collectFunction( + ' 1 && $n < 10) {' . "\n" + . ' return 1;' . "\n" + . ' }' . "\n" + . ' foreach ([1, 2] as $item) {' . "\n" + . ' $n += $item ?? 0;' . "\n" + . ' }' . "\n" + . ' return $n;' . "\n" + . '}' + ); + + // 1 + if + && + foreach + ?? + $this->assertSame(5, $functionNode->cyclomaticComplexity); + $this->assertSame(7, $functionNode->lineCount); + } + + public function testFunctionNodesAreCollectedAfterClassNodesInSourceOrder(): void + { + $analysisNodeCollector = $this->makeCollector( + 'assertSame(['App\Domain\Foo'], [$analysisNodeCollector->getClassNodes()[0]->className]); + $this->assertSame( + ['App\Domain\first', 'App\Domain\second'], + [ + $analysisNodeCollector->getFunctionNodes()[0]->functionName, + $analysisNodeCollector->getFunctionNodes()[1]->functionName, + ] + ); + } + + public function testCollectsTopLevelClosure(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + 'assertSame(self::FILE, $anonymousFunctionNode->file); + $this->assertSame(2, $anonymousFunctionNode->line); + $this->assertSame('Domain', $anonymousFunctionNode->layer); + $this->assertFalse($anonymousFunctionNode->isArrowFunction); + $this->assertFalse($anonymousFunctionNode->isStatic); + $this->assertNull($anonymousFunctionNode->enclosingClassName); + $this->assertNull($anonymousFunctionNode->enclosingFunctionName); + $this->assertTrue($anonymousFunctionNode->hasReturnType); + $this->assertSame(2, $anonymousFunctionNode->paramCount); + $this->assertSame(1, $anonymousFunctionNode->cyclomaticComplexity); + $this->assertSame(1, $anonymousFunctionNode->lineCount); + } + + public function testCollectsStaticArrowFunction(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + ' $a > 1 ? $a : 0;' + ); + + $this->assertTrue($anonymousFunctionNode->isArrowFunction); + $this->assertTrue($anonymousFunctionNode->isStatic); + $this->assertFalse($anonymousFunctionNode->hasReturnType); + $this->assertSame(1, $anonymousFunctionNode->paramCount); + $this->assertSame(2, $anonymousFunctionNode->cyclomaticComplexity); + $this->assertSame(1, $anonymousFunctionNode->lineCount); + $this->assertSame('Arrow function', $anonymousFunctionNode->getType()); + } + + public function testRecordsEnclosingClassAndCountsClosureBodyOnBothNodes(): void + { + $analysisNodeCollector = $this->makeCollector( + 'getAnonymousFunctionNodes()[0]; + $classNode = $analysisNodeCollector->getClassNodes()[0]; + + $this->assertSame('App\Domain\Handler', $anonymousFunctionNode->enclosingClassName); + $this->assertNull($anonymousFunctionNode->enclosingFunctionName); + $this->assertSame('App\Domain\Handler', $anonymousFunctionNode->enclosingScopeName()); + $this->assertSame(5, $anonymousFunctionNode->line); + + // Neither function-like inherits the namespace imports; the class does. + $this->assertSame(['App\Infrastructure\Mailer'], $anonymousFunctionNode->dependencies); + $this->assertSame(['App\Infrastructure\Mailer'], $classNode->dependencies); + + $this->assertSame(['strlen'], $anonymousFunctionNode->functionCalls); + $this->assertSame(['strlen'], $classNode->functionCalls); + $this->assertSame(['exit'], $anonymousFunctionNode->languageConstructs); + $this->assertSame(['exit'], $classNode->languageConstructs); + } + + public function testRecordsEnclosingFunctionForClosureInsideNamedFunction(): void + { + $analysisNodeCollector = $this->makeCollector( + ' $_POST["x"] ?? null; }' + ); + + $anonymousFunctionNode = $analysisNodeCollector->getAnonymousFunctionNodes()[0]; + $functionNode = $analysisNodeCollector->getFunctionNodes()[0]; + + $this->assertNull($anonymousFunctionNode->enclosingClassName); + $this->assertSame('App\Domain\build', $anonymousFunctionNode->enclosingFunctionName); + $this->assertSame('App\Domain\build', $anonymousFunctionNode->enclosingScopeName()); + $this->assertSame(['$_POST'], $anonymousFunctionNode->superglobals); + $this->assertSame(2, $anonymousFunctionNode->cyclomaticComplexity); + + // The enclosing function sees the closure body too. + $this->assertSame(['$_POST'], $functionNode->superglobals); + $this->assertSame(2, $functionNode->cyclomaticComplexity); + } + + public function testNestedClosuresEachGetTheirOwnNodeAndComplexity(): void + { + $anonymousFunctionNodes = $this->makeCollector( + 'getAnonymousFunctionNodes(); + + $this->assertCount(2, $anonymousFunctionNodes); + // Source order: the outer closure is entered first. + $this->assertSame(2, $anonymousFunctionNodes[0]->line); + $this->assertSame(2, $anonymousFunctionNodes[0]->cyclomaticComplexity); + $this->assertSame(3, $anonymousFunctionNodes[1]->line); + $this->assertSame(2, $anonymousFunctionNodes[1]->cyclomaticComplexity); + } + + public function testClosureInsideAnonymousClassResolvesToTheNamedEnclosingClass(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + ' 1; } };' . "\n" + . ' }' . "\n" + . '}' + ); + + $this->assertSame('App\Domain\Factory', $anonymousFunctionNode->enclosingClassName); + } + + public function testClosureInsideTopLevelAnonymousClassHasNoEnclosingScope(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + ' 1; } };' + ); + + $this->assertNull($anonymousFunctionNode->enclosingClassName); + $this->assertNull($anonymousFunctionNode->enclosingFunctionName); + $this->assertSame('file scope', $anonymousFunctionNode->enclosingScopeName()); + } + + public function testTracksThisUsageThroughNestedClosuresButNotAcrossAnonymousClasses(): void + { + $anonymousFunctionNodes = $this->makeCollector( + ' 1;' . "\n" + . ' $outer = function () { return function () { return $this->x; }; };' . "\n" + . ' $anon = function () { return new class { public function run() { return $this; } }; };' . "\n" + . ' $static = static fn () => 2;' . "\n" + . ' }' . "\n" + . '}' + )->getAnonymousFunctionNodes(); + + $this->assertCount(5, $anonymousFunctionNodes); + $this->assertFalse($anonymousFunctionNodes[0]->usesThis, 'plain arrow function'); + $this->assertTrue($anonymousFunctionNodes[1]->usesThis, 'outer closure captures $this for the inner one'); + $this->assertTrue($anonymousFunctionNodes[2]->usesThis, 'inner closure reads $this'); + $this->assertFalse($anonymousFunctionNodes[3]->usesThis, '$this inside the anonymous class is its own'); + $this->assertFalse($anonymousFunctionNodes[4]->usesThis, 'static arrow function'); + $this->assertTrue($anonymousFunctionNodes[4]->isStatic); + } + + public function testIgnoresVariableVariablesWhenTrackingThisAndSuperglobals(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + 'assertFalse($anonymousFunctionNode->usesThis); + $this->assertFalse($anonymousFunctionNode->accessesSuperglobals()); + } + + public function testTracksThisUsageInTopLevelClosure(): void + { + $anonymousFunctionNode = $this->collectAnonymousFunction( + 'value; };' + ); + + $this->assertTrue($anonymousFunctionNode->usesThis); + } + + public function testIgnoresFunctionLikeExitWithoutMatchingEntry(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + + $analysisNodeCollector->leaveNode(new Closure()); + + $this->assertSame([], $analysisNodeCollector->getAnonymousFunctionNodes()); + } + + public function testMethodComplexityStillAggregatesNestedClosureBranches(): void + { + $classNode = $this->makeCollector( + 'getClassNodes()[0]; + + $this->assertSame(3, $classNode->methods[0]->cyclomaticComplexity); + } + + public function testResetsFunctionLikeStateBetweenFiles(): void + { + $namespaceLayerResolver = new NamespaceLayerResolver(['Domain' => 'src/Domain/'], self::BASE_PATH); + $analysisNodeCollector = new AnalysisNodeCollector($namespaceLayerResolver); + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $nodeTraverser = new NodeTraverser(new NameResolver(), $analysisNodeCollector); + + $analysisNodeCollector->setCurrentFile(self::BASE_PATH . '/src/Domain/a.php'); + $nodeTraverser->traverse($parser->parse(' 1; }') ?? []); + + $analysisNodeCollector->setCurrentFile(self::BASE_PATH . '/src/Domain/b.php'); + $nodeTraverser->traverse($parser->parse('getFunctionNodes(); + + $this->assertCount(2, $functionNodes); + $this->assertSame(self::BASE_PATH . '/src/Domain/a.php', $functionNodes[0]->file); + $this->assertSame(self::BASE_PATH . '/src/Domain/b.php', $functionNodes[1]->file); + $this->assertCount(1, $analysisNodeCollector->getAnonymousFunctionNodes()); + } +} diff --git a/tests/Analyser/FunctionNodeTest.php b/tests/Analyser/FunctionNodeTest.php new file mode 100644 index 00000000..ce61b0fa --- /dev/null +++ b/tests/Analyser/FunctionNodeTest.php @@ -0,0 +1,95 @@ +assertSame('format_money', $functionNode->shortName()); + $this->assertSame(['Support'], $functionNode->layers); + $this->assertTrue($functionNode->isInLayer('Support')); + $this->assertFalse($functionNode->isInLayer('Domain')); + $this->assertTrue($functionNode->nameStartsWith('format_')); + $this->assertTrue($functionNode->nameEndsWith('_money')); + $this->assertTrue($functionNode->nameMatches('/^format_/')); + $this->assertFalse($functionNode->nameMatches('/^App\\\\Support\\\\format_money$/')); + $this->assertTrue($functionNode->nameMatches('/^App\\\\Support\\\\format_money$/', isFullName: true)); + } + + public function testGlobalFunctionShortNameIsItsName(): void + { + $functionNode = new FunctionNode(functionName: 'helper', file: '/src/helpers.php', line: 1, layer: null); + + $this->assertSame('helper', $functionNode->shortName()); + $this->assertSame([], $functionNode->layers); + } + + public function testExplicitLayersOverrideSingleLayer(): void + { + $functionNode = new FunctionNode( + functionName: 'helper', + file: '/src/helpers.php', + line: 1, + layer: 'Support', + layers: ['Support', 'Source'], + ); + + $this->assertTrue($functionNode->isInLayer('Source')); + } + + public function testBodyQueries(): void + { + $functionNode = new FunctionNode( + functionName: 'App\\render', + file: '/src/helpers.php', + line: 1, + layer: 'Support', + dependencies: ['App\\View\\Template', 'Psr\\Log\\LoggerInterface'], + functionCalls: ['App\\escape', 'sprintf'], + superglobals: ['$_GET'], + languageConstructs: ['die'], + ); + + $this->assertTrue($functionNode->dependsOn('App\\View\\Template')); + $this->assertFalse($functionNode->dependsOn('App\\View\\Renderer')); + $this->assertTrue($functionNode->dependsOnNamespace('Psr\\Log')); + $this->assertTrue($functionNode->dependsOnNamespace('Psr\\Log\\')); + $this->assertFalse($functionNode->dependsOnNamespace('Psr\\Http')); + $this->assertTrue($functionNode->callsFunction('SPRINTF')); + $this->assertFalse($functionNode->callsFunction('printf')); + $this->assertTrue($functionNode->accessesSuperglobals()); + $this->assertTrue($functionNode->usesLanguageConstruct('die')); + $this->assertTrue($functionNode->usesLanguageConstruct('exit')); + $this->assertFalse($functionNode->usesLanguageConstruct('echo')); + } + + public function testExitAliasesDieAndNothingElse(): void + { + $functionNode = new FunctionNode( + functionName: 'stop', + file: '/src/helpers.php', + line: 1, + layer: null, + languageConstructs: ['exit'], + ); + + $this->assertTrue($functionNode->usesLanguageConstruct('die')); + $this->assertFalse($functionNode->usesLanguageConstruct('eval')); + $this->assertFalse($functionNode->accessesSuperglobals()); + } +} diff --git a/tests/Analyser/Parallel/ClassNodeWorkerTest.php b/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php similarity index 67% rename from tests/Analyser/Parallel/ClassNodeWorkerTest.php rename to tests/Analyser/Parallel/AnalysisNodeWorkerTest.php index 8173735b..1d75389a 100644 --- a/tests/Analyser/Parallel/ClassNodeWorkerTest.php +++ b/tests/Analyser/Parallel/AnalysisNodeWorkerTest.php @@ -4,9 +4,11 @@ namespace Boundwize\StructArmed\Tests\Analyser\Parallel; -use Boundwize\StructArmed\Analyser\Parallel\ClassNodeWorker; +use Boundwize\StructArmed\Analyser\Parallel\AnalysisNodeWorker; use Boundwize\StructArmed\Analyser\Parallel\WorkerFailedException; use Boundwize\StructArmed\Analyser\Parallel\WorkerProgressHandler; +use Boundwize\StructArmed\Cache\AnalysisResultCache; +use Boundwize\StructArmed\Cache\FileHashProvider; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -18,10 +20,10 @@ use function serialize; use function unserialize; -#[CoversClass(ClassNodeWorker::class)] +#[CoversClass(AnalysisNodeWorker::class)] #[CoversClass(WorkerProgressHandler::class)] #[CoversClass(WorkerFailedException::class)] -final class ClassNodeWorkerTest extends TestCase +final class AnalysisNodeWorkerTest extends TestCase { use TemporaryDirectoryCleanupTrait; @@ -50,7 +52,7 @@ final class Foo 'files' => [$srcFile], ])); - $exitCode = ClassNodeWorker::run($inputFile, $outputFile, $this->silentStream()); + $exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()); $this->assertSame(0, $exitCode); @@ -63,6 +65,44 @@ final class Foo $this->assertIsArray($result['nodes']); } + public function testRunStoresAnalysisNodesInSuppliedCache(): void + { + $dir = $this->makeTemporaryDirectory('structarmed-worker-test'); + $cacheDir = $this->makeTemporaryDirectory('structarmed-worker-cache'); + $srcFile = $dir . '/Foo.php'; + + file_put_contents($srcFile, <<<'PHP' +makeTemporaryFile('structarmed-worker-input'); + $outputFile = $this->makeTemporaryFile('structarmed-worker-output'); + + file_put_contents($inputFile, serialize([ + 'basePath' => $dir, + 'layers' => ['Domain' => 'App\\Domain'], + 'layerPatterns' => [], + 'files' => [$srcFile], + 'cache' => new AnalysisResultCache($dir, new FileHashProvider(), $cacheDir), + 'cacheNamespace' => 'namespace', + ])); + + $this->assertSame(0, AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream())); + + $cached = (new AnalysisResultCache($dir, new FileHashProvider(), $cacheDir)) + ->loadAnalysisNodesWithFileAnalysis($srcFile, 'namespace'); + + $this->assertNotNull($cached); + $this->assertCount(1, $cached['classNodes']); + $this->assertSame('App\\Domain\\Foo', $cached['classNodes'][0]->className); + } + public function testRunWithInvalidPayloadReturnsOneAndWritesError(): void { $inputFile = $this->makeTemporaryFile('structarmed-worker-input'); @@ -70,7 +110,7 @@ public function testRunWithInvalidPayloadReturnsOneAndWritesError(): void file_put_contents($inputFile, serialize('not-an-array')); - $exitCode = ClassNodeWorker::run($inputFile, $outputFile, $this->silentStream()); + $exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()); $this->assertSame(1, $exitCode); @@ -114,7 +154,7 @@ final class FooService 'files' => [$srcFile], ])); - $exitCode = ClassNodeWorker::run($inputFile, $outputFile, $this->silentStream()); + $exitCode = AnalysisNodeWorker::run($inputFile, $outputFile, $this->silentStream()); $this->assertSame(0, $exitCode); diff --git a/tests/Analyser/Parallel/ParallelClassNodeExtractorTest.php b/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php similarity index 69% rename from tests/Analyser/Parallel/ParallelClassNodeExtractorTest.php rename to tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php index 09147e9d..d1e298b6 100644 --- a/tests/Analyser/Parallel/ParallelClassNodeExtractorTest.php +++ b/tests/Analyser/Parallel/ParallelAnalysisNodeExtractorTest.php @@ -5,7 +5,9 @@ namespace Boundwize\StructArmed\Tests\Analyser\Parallel; use Boundwize\StructArmed\Analyser\ClassNode; -use Boundwize\StructArmed\Analyser\Parallel\ParallelClassNodeExtractor; +use Boundwize\StructArmed\Analyser\Parallel\ParallelAnalysisNodeExtractor; +use Boundwize\StructArmed\Cache\AnalysisResultCache; +use Boundwize\StructArmed\Cache\FileHashProvider; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; use Iterator; use PHPUnit\Framework\Attributes\CoversClass; @@ -24,21 +26,21 @@ use const PHP_BINARY; -#[CoversClass(ParallelClassNodeExtractor::class)] -final class ParallelClassNodeExtractorTest extends TestCase +#[CoversClass(ParallelAnalysisNodeExtractor::class)] +final class ParallelAnalysisNodeExtractorTest extends TestCase { use TemporaryDirectoryCleanupTrait; public function testExtractWithEmptyFilesReturnsEmpty(): void { - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: '/tmp', layers: ['Domain' => 'App\\Domain'], layerPatterns: [], workerCount: 4, ); - $extractionResult = $parallelClassNodeExtractor->extract([]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([]); $this->assertSame([], $extractionResult->classNodes); $this->assertSame([], $extractionResult->fileAnalyses); @@ -59,14 +61,14 @@ final class Foo } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: [], workerCount: 1, ); - $extractionResult = $parallelClassNodeExtractor->extract([$file]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); $this->assertInstanceOf(ClassNode::class, $extractionResult->classNodes[0]); @@ -99,14 +101,14 @@ final class Bar } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: [], workerCount: 2, ); - $extractionResult = $parallelClassNodeExtractor->extract([$file1, $file2]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([$file1, $file2]); $this->assertCount(2, $extractionResult->classNodes); $classNames = [$extractionResult->classNodes[0]->className, $extractionResult->classNodes[1]->className]; @@ -121,7 +123,7 @@ public function testExtractReturnsWorkerFacts(): void file_put_contents($file, ' ''], [], 2)) + $extractionResult = (new ParallelAnalysisNodeExtractor($dir, ['Source' => ''], [], 2)) ->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); @@ -146,7 +148,7 @@ final class Baz } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: [], @@ -154,12 +156,51 @@ final class Baz cacheDirectory: $cacheDir, ); - $extractionResult = $parallelClassNodeExtractor->extract([$file]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); $this->assertSame('App\\Domain\\Baz', $extractionResult->classNodes[0]->className); } + public function testWorkersStoreValidAnalysisNodeCacheEntriesWithScopedFileHashes(): void + { + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $cacheDir = $this->makeTemporaryDirectory('structarmed-parallel-cache'); + $fooFile = $dir . '/Foo.php'; + $barFile = $dir . '/Bar.php'; + + file_put_contents($fooFile, 'hash($fooFile); + $fileHashProvider->hash($barFile); + + $analysisResultCache = new AnalysisResultCache($dir, $fileHashProvider, $cacheDir); + + (new ParallelAnalysisNodeExtractor( + basePath: $dir, + layers: [], + layerPatterns: [], + workerCount: 2, + cacheDirectory: $cacheDir, + analysisResultCache: $analysisResultCache, + analysisNodeCacheNamespace: 'config', + ))->extract([$fooFile, $barFile]); + + $freshCache = new AnalysisResultCache($dir, new FileHashProvider(), $cacheDir); + + $this->assertIsArray($freshCache->loadAnalysisNodes($fooFile, 'config')); + $this->assertIsArray($freshCache->loadAnalysisNodes($barFile, 'config')); + + file_put_contents($fooFile, 'assertNull($changedFileCache->loadAnalysisNodes($fooFile, 'config')); + $this->assertIsArray($changedFileCache->loadAnalysisNodes($barFile, 'config')); + } + public function testExtractWithLayerPatternsUsesChainResolver(): void { $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); @@ -175,14 +216,14 @@ final class FooService } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: ['Domain' => ['pattern' => '/Service$/', 'excludePattern' => null]], workerCount: 2, ); - $extractionResult = $parallelClassNodeExtractor->extract([$file]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); } @@ -202,14 +243,14 @@ final class FooService } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: ['Domain' => ['pattern' => '/Service$/', 'excludePattern' => null]], workerCount: 1, ); - $extractionResult = $parallelClassNodeExtractor->extract([$file]); + $extractionResult = $parallelAnalysisNodeExtractor->extract([$file]); $this->assertCount(1, $extractionResult->classNodes); $this->assertSame('App\\Domain\\FooService', $extractionResult->classNodes[0]->className); @@ -219,11 +260,11 @@ public function testExtractThrowsWhenWorkerFailsDueToNullByteInFilePath(): void { $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); // A null byte in a file path causes PHP 8 to throw ValueError in file_get_contents, - // which is NOT caught by ClassNodeExtractor's catch(PhpParser\Error), so it - // propagates to ClassNodeWorker's catch(Throwable) → worker exits with code 1 + // which is NOT caught by AnalysisNodeExtractor's catch(PhpParser\Error), so it + // propagates to AnalysisNodeWorker's catch(Throwable) → worker exits with code 1 $fileWithNullByte = $dir . "/foo\x00.php"; - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: [], @@ -231,7 +272,7 @@ public function testExtractThrowsWhenWorkerFailsDueToNullByteInFilePath(): void ); $this->expectException(RuntimeException::class); - $parallelClassNodeExtractor->extract([$fileWithNullByte]); + $parallelAnalysisNodeExtractor->extract([$fileWithNullByte]); } public function testExtractWithNonExistentCacheDirectoryCreatesIt(): void @@ -250,7 +291,7 @@ final class Qux } PHP); - $parallelClassNodeExtractor = new ParallelClassNodeExtractor( + $parallelAnalysisNodeExtractor = new ParallelAnalysisNodeExtractor( basePath: $dir, layers: ['Domain' => 'App\\Domain'], layerPatterns: [], @@ -259,7 +300,7 @@ final class Qux ); try { - $result = $parallelClassNodeExtractor->extract([$file]); + $result = $parallelAnalysisNodeExtractor->extract([$file]); $this->assertCount(1, $result->classNodes); } finally { if (is_dir($cacheDir)) { @@ -280,13 +321,13 @@ public function testExtractThrowsWhenProcOpenFails(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Unable to start parallel analysis worker.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_proc_open'] = false; } @@ -294,7 +335,7 @@ public function testExtractThrowsWhenProcOpenFails(): void public function testExtractReportsStderrWhenWorkerDiesBeforeWritingPayload(): void { - // Simulates a worker killed by OOM / fatal error before ClassNodeWorker can serialize a result: + // Simulates a worker killed by OOM / fatal error before AnalysisNodeWorker can serialize a result: // non-zero exit code, empty output file, diagnostic on stderr. $GLOBALS['mock_proc_open_command'] = [ PHP_BINARY, @@ -306,10 +347,10 @@ public function testExtractReportsStderrWhenWorkerDiesBeforeWritingPayload(): vo $file = $dir . '/Foo.php'; file_put_contents($file, 'extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); $this->fail('Expected RuntimeException was not thrown.'); } catch (RuntimeException $runtimeException) { $this->assertStringContainsString('Parallel analysis worker failed:', $runtimeException->getMessage()); @@ -329,13 +370,13 @@ public function testExtractThrowsWhenTempnamFails(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Unable to create temporary file for parallel analysis.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_tempnam'] = false; } @@ -349,13 +390,13 @@ public function testExtractThrowsWhenPayloadIsInvalid(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned an invalid payload.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -372,13 +413,13 @@ public function testExtractThrowsWhenExitZeroWorkerReportsErrorInPayload(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker failed: simulated payload error'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -393,13 +434,13 @@ public function testExtractThrowsWhenErrorPayloadIsInvalid(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned an invalid error payload.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -418,13 +459,13 @@ public function testExtractThrowsWhenFileAnalysesPayloadIsNotAnArray(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid file analyses.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -443,13 +484,13 @@ public function testExtractThrowsWhenFileAnalysisEntryIsInvalid(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid file analyses.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -469,13 +510,13 @@ public function testExtractThrowsWhenAnonymousClassNodesPayloadIsNotAnArray(): v $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid anonymous class nodes.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -495,13 +536,13 @@ public function testExtractThrowsWhenAnonymousClassNodeEntryIsInvalid(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid anonymous class nodes.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -522,13 +563,13 @@ public function testExtractThrowsWhenFileReferencesPayloadIsNotAnArray(): void $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid file references.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -570,13 +611,74 @@ public function testExtractThrowsWhenFileInstantiationsPayloadIsInvalid(mixed $i $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid file instantiations.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); + } finally { + $GLOBALS['mock_file_get_contents_payload'] = null; + $GLOBALS['mock_tracked_tempnam_files'] = []; + } + } + + #[DataProvider('invalidFunctionNodesProvider')] + public function testExtractThrowsWhenFunctionNodesPayloadIsInvalid(mixed $invalidFunctionNodes): void + { + $GLOBALS['mock_file_get_contents_payload'] = [ + 'nodes' => [], + 'fileAnalyses' => [], + 'functionNodes' => $invalidFunctionNodes, + 'error' => null, + ]; + + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $file = $dir . '/Foo.php'; + file_put_contents($file, 'expectException(RuntimeException::class); + $this->expectExceptionMessage('Parallel analysis worker returned invalid function nodes.'); + + try { + $parallelAnalysisNodeExtractor->extract([$file]); + } finally { + $GLOBALS['mock_file_get_contents_payload'] = null; + $GLOBALS['mock_tracked_tempnam_files'] = []; + } + } + + /** @return Iterator */ + public static function invalidFunctionNodesProvider(): Iterator + { + yield 'not an array' => ['invalid']; + yield 'entry is not a node' => [['invalid']]; + } + + #[DataProvider('invalidFunctionNodesProvider')] + public function testExtractThrowsWhenAnonymousFunctionNodesPayloadIsInvalid(mixed $invalidNodes): void + { + $GLOBALS['mock_file_get_contents_payload'] = [ + 'nodes' => [], + 'fileAnalyses' => [], + 'anonymousFunctionNodes' => $invalidNodes, + 'error' => null, + ]; + + $dir = $this->makeTemporaryDirectory('structarmed-parallel-test'); + $file = $dir . '/Foo.php'; + file_put_contents($file, 'expectException(RuntimeException::class); + $this->expectExceptionMessage('Parallel analysis worker returned invalid anonymous function nodes.'); + + try { + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; @@ -598,13 +700,13 @@ public function testExtractThrowsWhenFileReferencesEntryIsInvalid(mixed $invalid $file = $dir . '/Foo.php'; file_put_contents($file, 'expectException(RuntimeException::class); $this->expectExceptionMessage('Parallel analysis worker returned invalid file references.'); try { - $parallelClassNodeExtractor->extract([$file]); + $parallelAnalysisNodeExtractor->extract([$file]); } finally { $GLOBALS['mock_file_get_contents_payload'] = null; $GLOBALS['mock_tracked_tempnam_files'] = []; diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 5ec96665..ae31bd73 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -6,10 +6,13 @@ use App\Foo; use Boundwize\StructArmed\Analyser\AnonymousClassNode; +use Boundwize\StructArmed\Analyser\AnonymousFunctionNode; use Boundwize\StructArmed\Analyser\ClassNode; use Boundwize\StructArmed\Analyser\ConstantNode; use Boundwize\StructArmed\Analyser\EnumCaseNode; +use Boundwize\StructArmed\Analyser\ExtractionResult; use Boundwize\StructArmed\Analyser\FileAnalysis; +use Boundwize\StructArmed\Analyser\FunctionNode; use Boundwize\StructArmed\Analyser\MethodNode; use Boundwize\StructArmed\Analyser\PropertyNode; use Boundwize\StructArmed\Cache\AnalysisCacheMetadataFactory; @@ -35,6 +38,9 @@ use function glob; use function hash; use function hash_file; +use function hash_final; +use function hash_init; +use function hash_update; use function is_dir; use function json_decode; use function json_encode; @@ -66,6 +72,42 @@ public function testGetCacheDirectoryReturnsConfiguredDirectory(): void } } + public function testForFilesUsesOnlyRequestedMemoisedHashes(): void + { + $directory = $this->createTempDirectory(); + $cacheDir = $this->createTempDirectory(); + $retainedFile = $directory . '/Retained.php'; + $unrelatedFile = $directory . '/Unrelated.php'; + + file_put_contents($retainedFile, 'hash($retainedFile); + $fileHashProvider->hash($unrelatedFile); + + $cacheForFile = (new AnalysisResultCache($directory, $fileHashProvider, $cacheDir)) + ->forFiles([$retainedFile]); + + file_put_contents($retainedFile, 'storeAnalysisNodes($retainedFile, 'config', []); + $cacheForFile->storeAnalysisNodes($unrelatedFile, 'config', []); + + $analysisResultCache = new AnalysisResultCache($directory, new FileHashProvider(), $cacheDir); + + $this->assertNull($analysisResultCache->loadAnalysisNodes($retainedFile, 'config')); + $this->assertIsArray($analysisResultCache->loadAnalysisNodes($unrelatedFile, 'config')); + } finally { + unlink($retainedFile); + unlink($unrelatedFile); + $this->removeTempDirectory($directory); + $this->removeTempDirectory($cacheDir); + } + } + public function testStoresAndLoadsViolationCollection(): void { $cacheDirectory = $this->createTempDirectory(); @@ -83,6 +125,7 @@ className: self::class, methodName: 'save', constantName: 'VERSION', propertyName: 'status', + numericLiteral: '10000', )); try { @@ -399,7 +442,7 @@ public function testInvalidationIgnoresStoredPayloadMetadata(): void file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $analysisResultCache->store('key', ['configHash' => 'other'], new RuleViolationCollection()); $this->assertFalse($analysisResultCache->shouldInvalidate()); @@ -409,6 +452,68 @@ public function testInvalidationIgnoresStoredPayloadMetadata(): void } } + public function testCacheFromOlderFormatVersionIsInvalidated(): void + { + $cacheDirectory = $this->createTempDirectory(); + $analysisResultCache = new AnalysisResultCache( + __DIR__, + new FileHashProvider(), + $cacheDirectory, + 'same', + 'composer-hash', + ); + + try { + // A marker written by a release before the format version was + // recorded, or by an older format version, with otherwise + // matching hashes. + file_put_contents($cacheDirectory . '/_metadata.json', json_encode([ + 'configHash' => 'same', + 'composerGeneratedVersionHash' => 'composer-hash', + ], JSON_THROW_ON_ERROR)); + + $this->assertTrue($analysisResultCache->shouldInvalidate()); + + file_put_contents($cacheDirectory . '/_metadata.json', json_encode([ + 'version' => AnalysisResultCache::FORMAT_VERSION - 1, + 'configHash' => 'same', + 'composerGeneratedVersionHash' => 'composer-hash', + ], JSON_THROW_ON_ERROR)); + + $this->assertTrue($analysisResultCache->shouldInvalidate()); + + $analysisResultCache->clear(); + $analysisResultCache->store('key', [], new RuleViolationCollection()); + + $this->assertFalse($analysisResultCache->shouldInvalidate()); + } finally { + $this->removeTempDirectory($cacheDirectory); + } + } + + public function testCacheIsInvalidatedWhenComposerJsonChanges(): void + { + $basePath = $this->createTempDirectory(); + $cacheDirectory = $this->createTempDirectory(); + file_put_contents($basePath . '/composer.json', '{"autoload": {"psr-4": {"App\\\\": "src/"}}}'); + + try { + $analysisResultCache = new AnalysisResultCache($basePath, new FileHashProvider(), $cacheDirectory); + $analysisResultCache->store('key', [], new RuleViolationCollection()); + + $this->assertFalse($analysisResultCache->shouldInvalidate()); + + file_put_contents($basePath . '/composer.json', '{"autoload": {"psr-4": {"App\\\\": "lib/"}}}'); + + $this->assertTrue( + (new AnalysisResultCache($basePath, new FileHashProvider(), $cacheDirectory))->shouldInvalidate() + ); + } finally { + $this->removeTempDirectory($basePath); + $this->removeTempDirectory($cacheDirectory); + } + } + public function testPopulatedCacheWithoutMetadataMarkerIsInvalidated(): void { $cacheDirectory = $this->createTempDirectory(); @@ -485,19 +590,19 @@ public function testClassNodeCacheNamespaceDependsOnConfigAndComposerJson(): voi $analysisCacheMetadataFactory = new AnalysisCacheMetadataFactory(new FileHashProvider()); try { - $withoutComposer = $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, 'config-hash'); + $withoutComposer = $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, 'config-hash'); $this->assertSame( $withoutComposer, - $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, 'config-hash') + $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, 'config-hash') ); $this->assertNotSame( $withoutComposer, - $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, 'other-config-hash') + $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, 'other-config-hash') ); file_put_contents($basePath . '/composer.json', '{"autoload":{"psr-4":{"App\\\\":"lib/"}}}'); - $withComposer = $analysisCacheMetadataFactory->classNodeCacheNamespace($basePath, 'config-hash'); + $withComposer = $analysisCacheMetadataFactory->analysisNodeCacheNamespace($basePath, 'config-hash'); $this->assertNotSame($withoutComposer, $withComposer); @@ -507,13 +612,74 @@ public function testClassNodeCacheNamespaceDependsOnConfigAndComposerJson(): voi $this->assertNotSame( $withComposer, - $nextRunMetadataFactory->classNodeCacheNamespace($basePath, 'config-hash') + $nextRunMetadataFactory->analysisNodeCacheNamespace($basePath, 'config-hash') ); } finally { $this->removeTempDirectory($basePath); } } + public function testStoreExtractionResultStoresOnePayloadPerFile(): void + { + $cacheDirectory = $this->createTempDirectory(); + $analysisResultCache = new AnalysisResultCache(__DIR__, new FileHashProvider(), $cacheDirectory); + $fileWithNodes = __FILE__; + $fileWithoutNodes = __DIR__ . '/FileHashProviderTest.php'; + + try { + $analysisResultCache->storeExtractionResult( + [$fileWithNodes, $fileWithoutNodes], + 'namespace', + new ExtractionResult( + classNodes: [$this->makeClassNode($fileWithNodes)], + fileAnalyses: [], + anonymousClassNodes: [new AnonymousClassNode(file: $fileWithNodes, line: 7, extends: null)], + functionNodes: [ + new FunctionNode( + functionName: 'App\\format', + file: $fileWithNodes, + line: 3, + layer: 'Source', + hasReturnType: true, + paramCount: 0, + cyclomaticComplexity: 1, + lineCount: 1, + ), + ], + anonymousFunctionNodes: [ + new AnonymousFunctionNode( + file: $fileWithNodes, + line: 5, + layer: null, + isArrowFunction: true, + isStatic: true, + enclosingFunctionName: 'App\\format', + usesThis: false, + hasReturnType: false, + paramCount: 0, + cyclomaticComplexity: 1, + lineCount: 1, + ), + ], + ) + ); + + $withNodes = $analysisResultCache->loadAnalysisNodes($fileWithNodes, 'namespace'); + $withoutNodes = $analysisResultCache->loadAnalysisNodes($fileWithoutNodes, 'namespace'); + + $this->assertNotNull($withNodes); + $this->assertCount(1, $withNodes['classNodes']); + $this->assertCount(1, $withNodes['anonymousClassNodes']); + $this->assertCount(1, $withNodes['functionNodes']); + $this->assertCount(1, $withNodes['anonymousFunctionNodes']); + $this->assertNotNull($withoutNodes); + $this->assertSame([], $withoutNodes['classNodes']); + $this->assertSame([], $withoutNodes['functionNodes']); + } finally { + $this->removeTempDirectory($cacheDirectory); + } + } + public function testStoreCreatesMissingCacheDirectory(): void { $basePath = $this->createTempDirectory(); @@ -579,9 +745,9 @@ public function testStoresAndLoadsClassNodes(): void file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', $classNodes); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertStringNotContainsString( "\n", @@ -605,18 +771,36 @@ public function testStoresAndLoadsAnonymousClassNodes(): void $classNodes = [$this->makeClassNode($sourceFile)]; $anonymousClassNodes = [ new AnonymousClassNode( - file: $sourceFile, - line: 7, - extends: 'App\BaseHandler', - implements: ['App\Contract'], - traits: ['App\Helper'], + file: $sourceFile, + line: 7, + extends: 'App\BaseHandler', + implements: ['App\Contract'], + traits: ['App\Helper'], + layer: 'Source', + enclosingClassName: 'App\HandlerFactory', + hasEmptyParentheses: true, + layers: ['Source', 'Shared'], + isReadonly: true, + dependencies: ['App\BaseHandler', 'App\Contract'], + methods: [new MethodNode('__construct', 'public', false, false, 1, 1, 3, true, 8)], + constants: [new ConstantNode('LIMIT', 'public', true, 9)], + properties: [new PropertyNode('clock', 'private', true, 8)], + functionCalls: ['strtoupper'], + superglobals: ['$_GET'], + languageConstructs: ['exit'], + ), + new AnonymousClassNode( + file: $sourceFile, + line: 12, + extends: null, + enclosingFunctionName: 'App\make', ), ]; file_put_contents($sourceFile, 'storeClassNodes( + $analysisResultCache->storeAnalysisNodes( $sourceFile, 'config', $classNodes, @@ -626,7 +810,7 @@ traits: ['App\Helper'], ['App\InstantiatedInFunction'], ); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config'); $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded['classNodes']); @@ -642,6 +826,231 @@ traits: ['App\Helper'], } } + public function testStoresAndLoadsFunctionLikeNodes(): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/helpers.php'; + $analysisResultCache = new AnalysisResultCache(__DIR__, new FileHashProvider(), $cacheDirectory); + $functionNodes = [ + new FunctionNode( + functionName: 'App\\format', + file: $sourceFile, + line: 3, + layer: 'Source', + hasReturnType: true, + paramCount: 2, + cyclomaticComplexity: 4, + lineCount: 9, + dependencies: ['App\\Money'], + functionCalls: ['sprintf'], + superglobals: ['$_GET'], + languageConstructs: ['echo'], + layers: ['Source', 'Support'], + ), + ]; + $anonymousFunctionNodes = [ + new AnonymousFunctionNode( + file: $sourceFile, + line: 5, + layer: null, + isArrowFunction: true, + isStatic: true, + enclosingClassName: 'App\\Handler', + enclosingFunctionName: 'App\\format', + usesThis: true, + hasReturnType: false, + paramCount: 1, + cyclomaticComplexity: 2, + lineCount: 1, + dependencies: ['App\\Money'], + functionCalls: ['App\\helper'], + superglobals: [], + languageConstructs: ['exit'], + ), + ]; + + file_put_contents($sourceFile, 'storeAnalysisNodes( + $sourceFile, + 'config', + [], + null, + [], + [], + [], + $functionNodes, + $anonymousFunctionNodes, + ); + + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config'); + + $this->assertIsArray($loaded); + $this->assertEquals($functionNodes, $loaded['functionNodes']); + $this->assertEquals($anonymousFunctionNodes, $loaded['anonymousFunctionNodes']); + + // Compact payload: no per-node file, and empty lists are omitted. + $payload = json_decode((string) file_get_contents($this->firstJsonFile($cacheDirectory)), true); + + $this->assertIsArray($payload); + $this->assertIsArray($payload['functionNodes']); + $this->assertIsArray($payload['anonymousFunctionNodes']); + + $storedFunction = $payload['functionNodes'][0]; + $storedClosure = $payload['anonymousFunctionNodes'][0]; + + $this->assertIsArray($storedFunction); + $this->assertIsArray($storedClosure); + $this->assertArrayNotHasKey('file', $storedFunction); + $this->assertArrayNotHasKey('file', $storedClosure); + $this->assertArrayNotHasKey('superglobals', $storedClosure); + $this->assertArrayNotHasKey('layers', $storedClosure); + $this->assertSame(['App\\helper'], $storedClosure['functionCalls']); + + // Function-likes also survive the file-analysis load path. + $analysisResultCache->storeAnalysisNodes( + $sourceFile, + 'config', + [], + new FileAnalysis($sourceFile, false, true, null, true, true, false, 0), + [], + [], + [], + $functionNodes, + $anonymousFunctionNodes, + ); + + $loadedWithFileAnalysis = $analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config'); + + $this->assertIsArray($loadedWithFileAnalysis); + $this->assertEquals($functionNodes, $loadedWithFileAnalysis['functionNodes']); + $this->assertEquals($anonymousFunctionNodes, $loadedWithFileAnalysis['anonymousFunctionNodes']); + } finally { + if (file_exists($sourceFile)) { + unlink($sourceFile); + } + + $this->removeTempDirectory($cacheDirectory); + } + } + + public function testFilesWithoutFunctionLikesOmitTheirKeysAndLoadAsEmpty(): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/Foo.php'; + $fileHashProvider = new FileHashProvider(); + $analysisResultCache = new AnalysisResultCache(__DIR__, $fileHashProvider, $cacheDirectory); + + file_put_contents($sourceFile, 'storeAnalysisNodes($sourceFile, 'config', []); + + $payload = json_decode((string) file_get_contents($this->firstJsonFile($cacheDirectory)), true); + + $this->assertIsArray($payload); + $this->assertArrayNotHasKey('functionNodes', $payload); + $this->assertArrayNotHasKey('anonymousFunctionNodes', $payload); + + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config'); + + $this->assertIsArray($loaded); + $this->assertSame([], $loaded['functionNodes']); + $this->assertSame([], $loaded['anonymousFunctionNodes']); + } finally { + if (file_exists($sourceFile)) { + unlink($sourceFile); + } + + $this->removeTempDirectory($cacheDirectory); + } + } + + /** + * @param array $override + */ + #[DataProvider('corruptedFunctionLikePayloadProvider')] + public function testLoadClassNodesRejectsCorruptedFunctionLikePayload(array $override): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/Foo.php'; + $analysisResultCache = new AnalysisResultCache(__DIR__, new FileHashProvider(), $cacheDirectory); + + file_put_contents($sourceFile, 'storeAnalysisNodes($sourceFile, 'config', []); + + $cacheFile = $this->firstJsonFile($cacheDirectory); + $payload = json_decode((string) file_get_contents($cacheFile), true); + + $this->assertIsArray($payload); + file_put_contents($cacheFile, json_encode($override + $payload, JSON_THROW_ON_ERROR)); + + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); + } finally { + if (file_exists($sourceFile)) { + unlink($sourceFile); + } + + $this->removeTempDirectory($cacheDirectory); + } + } + + /** @return Iterator}> */ + public static function corruptedFunctionLikePayloadProvider(): Iterator + { + $validFunction = [ + 'functionName' => 'App\\format', + 'file' => '/src/helpers.php', + 'line' => 1, + 'layer' => null, + 'hasReturnType' => true, + 'paramCount' => 0, + 'cyclomaticComplexity' => 1, + 'lineCount' => 0, + 'dependencies' => [], + 'functionCalls' => [], + 'superglobals' => [], + 'languageConstructs' => [], + 'layers' => [], + ]; + $validClosure = [ + 'isArrowFunction' => false, + 'isStatic' => false, + 'enclosingClassName' => null, + 'enclosingFunctionName' => null, + 'usesThis' => false, + ] + $validFunction; + + yield 'function nodes not an array' => [['functionNodes' => 'invalid']]; + yield 'function node entry not an array' => [['functionNodes' => ['invalid']]]; + yield 'function node without name' => [['functionNodes' => [['functionName' => 1] + $validFunction]]]; + yield 'function node with invalid line' => [['functionNodes' => [['line' => '1'] + $validFunction]]]; + yield 'function node with invalid layer' => [['functionNodes' => [['layer' => 1] + $validFunction]]]; + yield 'function node with invalid dependencies' => [ + ['functionNodes' => [['dependencies' => [1]] + $validFunction]], + ]; + yield 'anonymous function nodes not an array' => [['anonymousFunctionNodes' => 'invalid']]; + yield 'anonymous function node entry not an array' => [['anonymousFunctionNodes' => ['invalid']]]; + yield 'anonymous function node with invalid arrow flag' => [ + ['anonymousFunctionNodes' => [['isArrowFunction' => 'yes'] + $validClosure]], + ]; + yield 'anonymous function node with invalid enclosing class' => [ + ['anonymousFunctionNodes' => [['enclosingClassName' => 1] + $validClosure]], + ]; + yield 'anonymous function node with invalid usesThis flag' => [ + ['anonymousFunctionNodes' => [['usesThis' => 'no'] + $validClosure]], + ]; + yield 'anonymous function node with invalid enclosing function' => [ + ['anonymousFunctionNodes' => [['enclosingFunctionName' => 1] + $validClosure]], + ]; + yield 'anonymous function node with invalid body' => [ + ['anonymousFunctionNodes' => [['lineCount' => '0'] + $validClosure]], + ]; + } + public function testClassNodesLoadOldCachePayloadWithoutAnonymousClassNodes(): void { $cacheDirectory = $this->createTempDirectory(); @@ -652,7 +1061,7 @@ public function testClassNodesLoadOldCachePayloadWithoutAnonymousClassNodes(): v file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', $classNodes); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); // Simulate a payload written before anonymous class nodes existed. $cacheFile = $this->firstJsonFile($cacheDirectory); @@ -661,7 +1070,7 @@ public function testClassNodesLoadOldCachePayloadWithoutAnonymousClassNodes(): v unset($payload['anonymousClassNodes']); file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config'); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config'); $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded['classNodes']); @@ -691,6 +1100,18 @@ public static function corruptedAnonymousClassNodesProvider(): Iterator yield 'entry with invalid traits' => [ [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'traits' => 'invalid']], ]; + yield 'entry with invalid isReadonly' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'isReadonly' => 'yes']], + ]; + yield 'entry with invalid dependencies' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'dependencies' => [1]]], + ]; + yield 'entry with invalid methods' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'methods' => ['invalid']]], + ]; + yield 'entry with invalid method tuple' => [ + [['file' => '/Foo.php', 'line' => 7, 'extends' => null, 'methods' => [['x']]]], + ]; } public function testLoadClassNodesRejectsCorruptedFileReferencesPayload(): void @@ -702,7 +1123,7 @@ public function testLoadClassNodesRejectsCorruptedFileReferencesPayload(): void file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $cacheFile = $this->firstJsonFile($cacheDirectory); $payload = json_decode((string) file_get_contents($cacheFile), true); @@ -710,7 +1131,7 @@ public function testLoadClassNodesRejectsCorruptedFileReferencesPayload(): void $payload['fileReferences'] = ['App\Contract', 1]; file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); - $this->assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); } finally { if (file_exists($sourceFile)) { unlink($sourceFile); @@ -729,7 +1150,7 @@ public function testLoadClassNodesRejectsCorruptedFileInstantiationsPayload(): v file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $cacheFile = $this->firstJsonFile($cacheDirectory); $payload = json_decode((string) file_get_contents($cacheFile), true); @@ -737,7 +1158,7 @@ public function testLoadClassNodesRejectsCorruptedFileInstantiationsPayload(): v $payload['fileInstantiations'] = ['App\Base', 1]; file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); - $this->assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); } finally { if (file_exists($sourceFile)) { unlink($sourceFile); @@ -757,7 +1178,7 @@ public function testLoadClassNodesRejectsCorruptedAnonymousClassNodesPayload(mix file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $cacheFile = $this->firstJsonFile($cacheDirectory); $payload = json_decode((string) file_get_contents($cacheFile), true); @@ -765,7 +1186,7 @@ public function testLoadClassNodesRejectsCorruptedAnonymousClassNodesPayload(mix $payload['anonymousClassNodes'] = $corrupted; file_put_contents($cacheFile, json_encode($payload, JSON_THROW_ON_ERROR)); - $this->assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); } finally { if (file_exists($sourceFile)) { unlink($sourceFile); @@ -797,8 +1218,8 @@ className: "App\\Invalid\xB1Name", file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertStringContainsString("\xEF\xBF\xBD", $loaded[0]->className); @@ -835,8 +1256,8 @@ className: 'App\FooTrait', ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertTrue($loaded[0]->isTrait); @@ -873,8 +1294,8 @@ className: 'App\Status', ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertTrue($loaded[0]->isEnum); @@ -912,8 +1333,8 @@ interfaceExtends: ['App\BaseMiddleware'], ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertSame(['App\BaseMiddleware'], $loaded[0]->interfaceExtends); @@ -967,9 +1388,9 @@ public function testClassNodesLoadOldCachePayloadWithoutInterfaceExtends(): void 'layers' => [], ], ], - ], 'class-nodes-' . hash('xxh128', "config\0" . $sourceFile) . '.json'); + ], 'analysis-nodes-' . hash('xxh128', "config\0" . $sourceFile) . '.json'); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertSame([], $loaded[0]->interfaceExtends); @@ -1019,8 +1440,8 @@ className: Foo::class, ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded); @@ -1062,8 +1483,8 @@ className: Foo::class, ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded); @@ -1112,8 +1533,8 @@ enumBackingType: 'string', ]; try { - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes); - $loaded = $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null; + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); + $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; $this->assertIsArray($loaded); $this->assertEquals($classNodes, $loaded); @@ -1141,11 +1562,11 @@ public function testStoreClassNodesCreatesMissingCacheDirectory(): void try { $analysisResultCache->clear(); - $analysisResultCache->storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $this->assertInstanceOf( ClassNode::class, - $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'][0] ?? null + $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'][0] ?? null ); } finally { $analysisResultCache->clear(); @@ -1163,8 +1584,8 @@ public function testClassNodesMissWhenCacheFileDoesNotExist(): void file_put_contents($sourceFile, 'assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); - $this->assertNull($analysisResultCache->loadClassNodesWithFileAnalysis($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -1185,15 +1606,17 @@ public function testStoresAndLoadsClassNodesWithFileAnalysis(): void declaresSymbols: true, hasSideEffects: false, sideEffectLine: 1, + nonCanonicalKeywordConstants: [[3, 'TRUE'], [5, '\\NULL']], + numericLiterals: [[7, '10000', 10000], [8, '1e10', 10000000000.0]], ); file_put_contents($sourceFile, 'makeClassNode($sourceFile)]; - $analysisResultCache->storeClassNodes($sourceFile, 'config', $classNodes, $fileAnalysis); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes, $fileAnalysis); - $loaded = $analysisResultCache->loadClassNodesWithFileAnalysis($sourceFile, 'config'); + $loaded = $analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config'); $this->assertNotNull($loaded); $this->assertEquals($classNodes, $loaded['classNodes']); @@ -1213,9 +1636,9 @@ public function testClassNodesWithFileAnalysisMissesLegacyEntryWithoutFileFacts( file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); - $this->assertNull($analysisResultCache->loadClassNodesWithFileAnalysis($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -1228,18 +1651,19 @@ public function testClassNodesWithFileAnalysisMissesLegacyEntryWithoutFileFacts( public static function malformedFileAnalysisProvider(): iterable { $valid = [ - 'file' => __FILE__, - 'hasUtf8Bom' => false, - 'hasValidUtf8' => true, - 'invalidPhpTagLine' => null, - 'hasValidAst' => true, - 'declaresSymbols' => true, - 'hasSideEffects' => false, - 'sideEffectLine' => 1, + 'file' => __FILE__, + 'hasUtf8Bom' => false, + 'hasValidUtf8' => true, + 'invalidPhpTagLine' => null, + 'hasValidAst' => true, + 'declaresSymbols' => true, + 'hasSideEffects' => false, + 'sideEffectLine' => 1, + 'nonCanonicalKeywordConstants' => [], + 'numericLiterals' => [], ]; yield 'numeric keys' => [[0 => 'bad']]; - yield 'invalid file' => [[...$valid, 'file' => 1]]; yield 'invalid BOM flag' => [[...$valid, 'hasUtf8Bom' => 'bad']]; yield 'invalid UTF-8 flag' => [[...$valid, 'hasValidUtf8' => 'bad']]; yield 'missing invalid tag line' => [ @@ -1258,6 +1682,19 @@ public static function malformedFileAnalysisProvider(): iterable yield 'invalid declaration flag' => [[...$valid, 'declaresSymbols' => 'bad']]; yield 'invalid side-effects flag' => [[...$valid, 'hasSideEffects' => 'bad']]; yield 'invalid side-effect line' => [[...$valid, 'sideEffectLine' => 'bad']]; + yield 'invalid keyword constants type' => [[...$valid, 'nonCanonicalKeywordConstants' => 'bad']]; + yield 'keyword constants not a list' => [[...$valid, 'nonCanonicalKeywordConstants' => ['a' => [1, 'TRUE']]]]; + yield 'keyword constant not a pair' => [[...$valid, 'nonCanonicalKeywordConstants' => [[1]]]]; + yield 'keyword constant with extra entry' => [ + [...$valid, 'nonCanonicalKeywordConstants' => [[1, 'TRUE', 'extra']]], + ]; + yield 'keyword constant with invalid line' => [[...$valid, 'nonCanonicalKeywordConstants' => [['1', 'TRUE']]]]; + yield 'keyword constant with invalid spelling' => [[...$valid, 'nonCanonicalKeywordConstants' => [[1, 1]]]]; + yield 'numeric literals not a list' => [[...$valid, 'numericLiterals' => ['bad' => [1, '10000', 10000]]]]; + yield 'numeric literal not a triple' => [[...$valid, 'numericLiterals' => [[1, '10000']]]]; + yield 'numeric literal with invalid line' => [[...$valid, 'numericLiterals' => [['1', '10000', 10000]]]]; + yield 'numeric literal with invalid spelling' => [[...$valid, 'numericLiterals' => [[1, 10000, 10000]]]]; + yield 'numeric literal with invalid value' => [[...$valid, 'numericLiterals' => [[1, '10000', '10000']]]]; } /** @param array $fileAnalysis */ @@ -1271,7 +1708,7 @@ public function testClassNodesWithFileAnalysisMissesMalformedFacts(array $fileAn file_put_contents($sourceFile, 'storeClassNodes( + $analysisResultCache->storeAnalysisNodes( $sourceFile, 'config', [$this->makeClassNode($sourceFile)], @@ -1284,7 +1721,7 @@ public function testClassNodesWithFileAnalysisMissesMalformedFacts(array $fileAn $payload['fileAnalysis'] = $fileAnalysis; $this->writeCachePayload($cacheDirectory, $payload, $cacheFile); - $this->assertNull($analysisResultCache->loadClassNodesWithFileAnalysis($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -1300,7 +1737,7 @@ public function testClassNodesWithFileAnalysisMissesMalformedNodesWhenFactsAreVa file_put_contents($sourceFile, 'storeClassNodes( + $analysisResultCache->storeAnalysisNodes( $sourceFile, 'config', [$this->makeClassNode($sourceFile)], @@ -1313,7 +1750,7 @@ public function testClassNodesWithFileAnalysisMissesMalformedNodesWhenFactsAreVa $payload['nodes'] = 'invalid'; $this->writeCachePayload($cacheDirectory, $payload, $cacheFile); - $this->assertNull($analysisResultCache->loadClassNodesWithFileAnalysis($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -1329,12 +1766,12 @@ public function testClassNodesMissWhenFileMetadataChanges(): void file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); file_put_contents($sourceFile, 'assertNull($nextRunCache->loadClassNodes($sourceFile, 'config')); + $this->assertNull($nextRunCache->loadAnalysisNodes($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -1378,12 +1815,12 @@ public function testClassNodesHitWhenOnlyFileMtimeChanges(): void file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', $classNodes); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', $classNodes); touch($sourceFile, 1234567890); $this->assertEquals( $classNodes, - $analysisResultCache->loadClassNodes($sourceFile, 'config')['classNodes'] ?? null + $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null ); } finally { unlink($sourceFile); @@ -1624,7 +2061,7 @@ public static function malformedClassNodePayloadProvider(): iterable ], ], ]; - yield 'method has numeric keys' => [ + yield 'method is not a tuple' => [ [ 'nodes' => [ [ @@ -1670,20 +2107,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'dependencies' => [], 'implements' => [], 'traits' => [], - 'methods' => [ - [ - 'name' => 'run', - 'visibility' => 'public', - 'hasReturnType' => true, - 'isStatic' => false, - 'paramCount' => 0, - 'cyclomaticComplexity' => 1, - 'lineCount' => 1, - 'hasExplicitVisibility' => true, - 'line' => 'bad', - 'isMagic' => false, - ], - ], + 'methods' => [['run', 'public', true, false, 0, 1, 1, true, 'bad', false]], 'constants' => [], 'properties' => [], 'functionCalls' => [], @@ -1693,7 +2117,7 @@ public static function malformedClassNodePayloadProvider(): iterable ], ], ]; - yield 'method has missing hasExplicitVisibility' => [ + yield 'method tuple is too short' => [ [ 'nodes' => [ [ @@ -1711,19 +2135,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'dependencies' => [], 'implements' => [], 'traits' => [], - 'methods' => [ - [ - 'name' => 'run', - 'visibility' => 'public', - 'hasReturnType' => true, - 'isStatic' => false, - 'paramCount' => 0, - 'cyclomaticComplexity' => 1, - 'lineCount' => 1, - 'line' => 1, - 'isMagic' => false, - ], - ], + 'methods' => [['run', 'public', true, false, 0, 1, 1, 1, false]], 'constants' => [], 'properties' => [], 'functionCalls' => [], @@ -1789,7 +2201,7 @@ public static function malformedClassNodePayloadProvider(): iterable ], ], ]; - yield 'constant has numeric keys' => [ + yield 'constant is not a tuple' => [ [ 'nodes' => [ [ @@ -1836,14 +2248,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'implements' => [], 'traits' => [], 'methods' => [], - 'constants' => [ - [ - 'name' => 'VERSION', - 'visibility' => 'public', - 'hasExplicitVisibility' => true, - 'line' => 'bad', - ], - ], + 'constants' => [['VERSION', 'public', true, 'bad']], 'properties' => [], 'functionCalls' => [], 'superglobals' => [], @@ -1908,7 +2313,7 @@ public static function malformedClassNodePayloadProvider(): iterable ], ], ]; - yield 'property has numeric keys' => [ + yield 'property is not a tuple' => [ [ 'nodes' => [ [ @@ -1956,14 +2361,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'traits' => [], 'methods' => [], 'constants' => [], - 'properties' => [ - [ - 'name' => 'name', - 'visibility' => 'private', - 'hasExplicitVisibility' => true, - 'line' => 'bad', - ], - ], + 'properties' => [['name', 'private', true, 'bad']], 'functionCalls' => [], 'superglobals' => [], 'layers' => [], @@ -2029,7 +2427,7 @@ public static function malformedClassNodePayloadProvider(): iterable ], ], ]; - yield 'enum case has non-string keys' => [ + yield 'enum case is not a tuple' => [ [ 'nodes' => [ [ @@ -2079,7 +2477,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'methods' => [], 'constants' => [], 'properties' => [], - 'enumCases' => [['name' => 'Hearts', 'line' => 'bad']], + 'enumCases' => [['Hearts', 'bad', null]], 'functionCalls' => [], 'superglobals' => [], 'layers' => [], @@ -2108,7 +2506,7 @@ public static function malformedClassNodePayloadProvider(): iterable 'methods' => [], 'constants' => [], 'properties' => [], - 'enumCases' => [['name' => 'Hearts', 'line' => 4, 'value' => ['bad']]], + 'enumCases' => [['Hearts', 4, ['bad']]], 'functionCalls' => [], 'superglobals' => [], 'layers' => [], @@ -2161,7 +2559,7 @@ public function testClassNodesMissWhenPayloadIsMalformed(array $payloadOverride) file_put_contents($sourceFile, 'storeClassNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $cacheFile = $this->firstJsonFile($cacheDirectory); $this->writeCachePayload($cacheDirectory, [ @@ -2173,7 +2571,7 @@ public function testClassNodesMissWhenPayloadIsMalformed(array $payloadOverride) ...$payloadOverride, ], $cacheFile); - $this->assertNull($analysisResultCache->loadClassNodes($sourceFile, 'config')); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); } finally { unlink($sourceFile); $this->removeTempDirectory($cacheDirectory); @@ -2200,9 +2598,14 @@ public function testMetadataIncludesConfigAndAnalysedFiles(): void $this->assertSame($directory, $metadata['basePath']); $this->assertSame($config, $metadata['configPath']); $this->assertSame(['src'], $metadata['scanPaths']); + $this->assertSame(5, $metadata['version']); $this->assertIsString($metadata['configHash']); $this->assertIsString($metadata['composerGeneratedVersionHash']); - $this->assertIsString($metadata['filesHash']); + + $filesHashContext = hash_init('xxh128'); + hash_update($filesHashContext, $source . "\0" . hash_file('xxh128', $source) . "\0"); + + $this->assertSame(hash_final($filesHashContext), $metadata['filesHash']); $this->assertSame( (new AnalysisCacheMetadataFactory(new FileHashProvider()))->key($metadata), (new AnalysisCacheMetadataFactory(new FileHashProvider()))->key($metadata) diff --git a/tests/Cache/FileHashProviderTest.php b/tests/Cache/FileHashProviderTest.php index dd49cd66..e9d44ee0 100644 --- a/tests/Cache/FileHashProviderTest.php +++ b/tests/Cache/FileHashProviderTest.php @@ -55,4 +55,29 @@ public function testDoesNotMemoiseFailedHash(): void $this->assertSame(hash_file('xxh128', $file), $fileHashProvider->hash($file)); } + + public function testForFilesCopiesOnlyRequestedMemoisedHashes(): void + { + $retainedFile = $this->makeTemporaryFile('structarmed-file-hash'); + $unrelatedFile = $this->makeTemporaryFile('structarmed-file-hash'); + $missingFile = $this->makeTemporaryFile('structarmed-file-hash'); + + file_put_contents($retainedFile, 'hash($retainedFile); + $fileHashProvider->hash($unrelatedFile); + + $providerForFiles = $fileHashProvider->forFiles([$retainedFile, $missingFile]); + + file_put_contents($retainedFile, 'assertSame($retainedHash, $providerForFiles->hash($retainedFile)); + $this->assertSame(hash_file('xxh128', $unrelatedFile), $providerForFiles->hash($unrelatedFile)); + $this->assertSame(hash_file('xxh128', $missingFile), $providerForFiles->hash($missingFile)); + } } diff --git a/tests/Cli/InitCommandTest.php b/tests/Cli/InitCommandTest.php index 2411e8b7..a9f5b365 100644 --- a/tests/Cli/InitCommandTest.php +++ b/tests/Cli/InitCommandTest.php @@ -49,6 +49,11 @@ public static function presetProvider(): iterable ' ->withPreset(Preset::MVC());', ]; + yield 'per' => [ + ['--preset=per'], + ' ->withPreset(Preset::PER());', + ]; + yield 'psr1' => [ ['--preset=psr1'], ' ->withPreset(Preset::PSR1());', @@ -74,16 +79,23 @@ public static function presetProvider(): iterable ' ->withPreset(Preset::YAGNI());', ]; + yield 'codequality' => [ + ['--preset=codequality'], + ' ->withPreset(Preset::CODEQUALITY());', + ]; + yield 'all' => [ ['--preset=all'], " ->withPresets(\n" + . " Preset::PSR4(),\n" . " Preset::PSR1(),\n" . " Preset::PSR12(),\n" + . " Preset::PER(),\n" . " Preset::PSR15(),\n" - . " Preset::PSR4(),\n" . " Preset::DDD(),\n" . " Preset::MVC(),\n" - . " Preset::YAGNI()\n" + . " Preset::YAGNI(),\n" + . " Preset::CODEQUALITY()\n" . " );", ]; } diff --git a/tests/Cli/StructArmedApplicationCommandRoutingTest.php b/tests/Cli/StructArmedApplicationCommandRoutingTest.php index b1df5d94..fb8f6229 100644 --- a/tests/Cli/StructArmedApplicationCommandRoutingTest.php +++ b/tests/Cli/StructArmedApplicationCommandRoutingTest.php @@ -35,7 +35,7 @@ public function testApplicationPrintsUsageWithoutCommand(): void $this->assertSame(0, $exitCode); $this->assertStringContainsString('structarmed --version', $output); $this->assertStringContainsString( - 'structarmed init [--preset=ddd|mvc|psr1|psr12|psr15|psr4|yagni|all]', + 'structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|codequality|all]', $output ); $this->assertStringContainsString('structarmed analyse|analyze', $output); diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 9292b847..219b21a4 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -152,6 +152,11 @@ public static function presetProvider(): iterable ' ->withPreset(Preset::MVC());', ]; + yield 'per' => [ + ['--preset=per'], + ' ->withPreset(Preset::PER());', + ]; + yield 'psr1' => [ ['--preset=psr1'], ' ->withPreset(Preset::PSR1());', @@ -177,16 +182,23 @@ public static function presetProvider(): iterable ' ->withPreset(Preset::YAGNI());', ]; + yield 'codequality' => [ + ['--preset=codequality'], + ' ->withPreset(Preset::CODEQUALITY());', + ]; + yield 'all' => [ ['--preset=all'], " ->withPresets(\n" + . " Preset::PSR4(),\n" . " Preset::PSR1(),\n" . " Preset::PSR12(),\n" + . " Preset::PER(),\n" . " Preset::PSR15(),\n" - . " Preset::PSR4(),\n" . " Preset::DDD(),\n" . " Preset::MVC(),\n" - . " Preset::YAGNI()\n" + . " Preset::YAGNI(),\n" + . " Preset::CODEQUALITY()\n" . " );", ]; } @@ -576,6 +588,65 @@ public function testAnalyseCommandFixesFixableViolations(): void } } + public function testAnalyseCommandCountsEveryViolationResolvedByOneFix(): void + { + $basePath = $this->createProjectDirectory(); + + // Two flagged closures start on the same line: fixing the first one + // makes both static, so the second has nothing left to fix, yet both + // violations are gone and must be counted. + file_put_contents($basePath . '/src/Handler.php', <<<'PHP' + 1, fn () => $this->value, function () { return 2; }]; + } +} +PHP); + file_put_contents($basePath . '/structarmed.php', <<<'PHP' +layer('Source', 'src/') + ->rule('source.static_closures', new MustBeStaticAnonymousFunctionRule(layer: 'Source')); +PHP); + + try { + [$exitCode, $output] = $this->runApplication( + [ + 'structarmed', + 'analyze', + '--config=' . $basePath . '/structarmed.php', + '--fix', + '--no-progress', + ], + $basePath + ); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('2 violations have been fixed.', $this->withoutAnsi($output)); + $this->assertStringContainsString('No violations found', $output); + $this->assertStringContainsString( + 'return [static fn () => 1, fn () => $this->value, static function () { return 2; }];', + (string) file_get_contents($basePath . '/src/Handler.php') + ); + } finally { + $this->removeTempDirectory($basePath); + } + } + public function testAnalyseCommandFixesCascadingYagniViolationsInOneRun(): void { $basePath = $this->createProjectDirectory(); @@ -1479,7 +1550,7 @@ public function testAnalyseCommandRejectsNonPhpFileScanPath(): void } } - public function testInternalWorkerRoutesDelegatestoClassNodeWorker(): void + public function testInternalWorkerRoutesDelegatestoAnalysisNodeWorker(): void { $inputFile = (string) tempnam(sys_get_temp_dir(), 'structarmed-worker-input-'); $outputFile = (string) tempnam(sys_get_temp_dir(), 'structarmed-worker-output-'); diff --git a/tests/Composer/ComposerJsonProviderTest.php b/tests/Composer/ComposerJsonProviderTest.php new file mode 100644 index 00000000..c9d74e34 --- /dev/null +++ b/tests/Composer/ComposerJsonProviderTest.php @@ -0,0 +1,59 @@ +assertNull($composerJsonProvider->config($this->makeTempDir())); + $this->assertNull($composerJsonProvider->config($this->makeTempProject('{not json'))); + $this->assertNull($composerJsonProvider->config($this->makeTempProject('["not", "an", "object"]'))); + } + + public function testMemoisesDecodedComposerJsonAcrossInstancesUntilCleared(): void + { + $basePath = $this->makeTempProject('{"name": "app/first"}'); + $composerJsonProvider = new ComposerJsonProvider(); + + $this->assertSame(['name' => 'app/first'], $composerJsonProvider->config($basePath)); + + // Same byte length, rewritten immediately: the memo is lifecycle-bound, + // not tied to filesystem metadata, so it is served until cleared. + file_put_contents($basePath . '/composer.json', '{"name": "app/other"}'); + + $this->assertSame(['name' => 'app/first'], (new ComposerJsonProvider())->config($basePath . '/')); + + $composerJsonProvider->clear(); + + $this->assertSame(['name' => 'app/other'], $composerJsonProvider->config($basePath)); + } + + private function makeTempProject(string $composerJson): string + { + $basePath = $this->makeTempDir(); + + file_put_contents($basePath . '/composer.json', $composerJson); + + return $basePath; + } + + private function makeTempDir(): string + { + return $this->makeTemporaryDirectory('structarmed-composer-json-provider'); + } +} diff --git a/tests/Preset/PresetTest.php b/tests/Preset/PresetTest.php index 067698f2..77c0b8f6 100644 --- a/tests/Preset/PresetTest.php +++ b/tests/Preset/PresetTest.php @@ -6,8 +6,10 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Preset\Preset; +use Boundwize\StructArmed\Preset\Presets\CodeQualityPreset; use Boundwize\StructArmed\Preset\Presets\DddPreset; use Boundwize\StructArmed\Preset\Presets\MvcPreset; +use Boundwize\StructArmed\Preset\Presets\PerPreset; use Boundwize\StructArmed\Preset\Presets\Psr12Preset; use Boundwize\StructArmed\Preset\Presets\Psr15Preset; use Boundwize\StructArmed\Preset\Presets\Psr1Preset; @@ -15,15 +17,20 @@ use Boundwize\StructArmed\Preset\Presets\ResolvesSourceLayerNameTrait; use Boundwize\StructArmed\Preset\Presets\YagniPreset; use Boundwize\StructArmed\Rule\Rules\Class_\ExtendedClassMustBeAbstractOrInstantiatedRule; +use Boundwize\StructArmed\Rule\Rules\Class_\MayNotExtendClassRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeUsedAbstractClassRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeUsedInterfaceRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeUsedTraitRule; +use Boundwize\StructArmed\Rule\Rules\File\LargeNumericLiteralMustUseSeparatorRule; +use Boundwize\StructArmed\Rule\Rules\Function_\MustBeStaticAnonymousFunctionRule; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(Preset::class)] +#[CoversClass(CodeQualityPreset::class)] #[CoversClass(DddPreset::class)] #[CoversClass(MvcPreset::class)] +#[CoversClass(PerPreset::class)] #[CoversClass(Psr1Preset::class)] #[CoversClass(Psr12Preset::class)] #[CoversClass(Psr15Preset::class)] @@ -71,6 +78,46 @@ public function testYagniPresetUsesComposerSourcePathsByDefault(): void $this->assertSame(['Source' => []], $architecture->getLayers()); } + public function testCodeQualityPresetRegistersSourceLayerAndRules(): void + { + $architecture = Architecture::define(); + + Preset::CODEQUALITY( + sourcePaths: ['src/'], + )->apply($architecture); + + $this->assertSame(['Source' => ['src/']], $architecture->getLayers()); + + $rules = $architecture->getRules(); + $this->assertCount(2, $rules); + $this->assertInstanceOf( + MustBeStaticAnonymousFunctionRule::class, + $rules[CodeQualityPreset::ANONYMOUS_FUNCTIONS_MUST_BE_STATIC] ?? null + ); + $this->assertInstanceOf( + LargeNumericLiteralMustUseSeparatorRule::class, + $rules[CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR] ?? null + ); + } + + public function testCodeQualityPresetUsesComposerSourcePathsByDefault(): void + { + $architecture = Architecture::define(); + + Preset::CODEQUALITY()->apply($architecture); + + // A null source path list defers to Composer-discovered PSR-4 paths. + $this->assertSame(['Source' => []], $architecture->getLayers()); + $this->assertArrayHasKey( + CodeQualityPreset::ANONYMOUS_FUNCTIONS_MUST_BE_STATIC, + $architecture->getRules() + ); + $this->assertArrayHasKey( + CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR, + $architecture->getRules() + ); + } + public function testPsr1PresetRegistersSourceLayerAndRules(): void { $architecture = Architecture::define(); @@ -94,7 +141,7 @@ public function testPsr1PresetRegistersSourceLayerAndRules(): void $this->assertArrayHasKey(Psr1Preset::METHODS_MUST_BE_CAMEL_CASE, $rules); } - public function testPsr12PresetAppliesPsr1RulesAndAddsVisibilityRules(): void + public function testPsr12PresetAppliesPsr1RulesAndAddsPsr12Rules(): void { $architecture = Architecture::define(); @@ -115,11 +162,58 @@ public function testPsr12PresetAppliesPsr1RulesAndAddsVisibilityRules(): void $this->assertArrayHasKey(Psr1Preset::CLASSES_MUST_BE_STUDLY_CAPS, $rules); $this->assertArrayHasKey(Psr1Preset::CLASS_CONSTANTS_MUST_BE_UPPER_CASE, $rules); $this->assertArrayHasKey(Psr1Preset::METHODS_MUST_BE_CAMEL_CASE, $rules); + $this->assertArrayHasKey(Psr12Preset::FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS, $rules); $this->assertArrayHasKey(Psr12Preset::METHODS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::CONSTANTS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::PROPERTIES_MUST_DECLARE_VISIBILITY, $rules); } + public function testPerPresetAppliesPsr12RulesAndAddsEnumCaseRule(): void + { + $architecture = Architecture::define(); + + Preset::PER( + sourcePaths: ['src/', 'tests/'], + )->apply($architecture); + + $this->assertSame(['Source' => ['src/', 'tests/']], $architecture->getLayers()); + + $rules = $architecture->getRules(); + $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_TAGS, $rules); + $this->assertArrayHasKey(Psr1Preset::CLASSES_MUST_BE_STUDLY_CAPS, $rules); + $this->assertArrayHasKey(Psr1Preset::CLASS_CONSTANTS_MUST_BE_UPPER_CASE, $rules); + $this->assertArrayHasKey(Psr1Preset::METHODS_MUST_BE_CAMEL_CASE, $rules); + $this->assertArrayHasKey(Psr12Preset::FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS, $rules); + $this->assertArrayHasKey(Psr12Preset::METHODS_MUST_DECLARE_VISIBILITY, $rules); + $this->assertArrayHasKey(Psr12Preset::CONSTANTS_MUST_DECLARE_VISIBILITY, $rules); + $this->assertArrayHasKey(Psr12Preset::PROPERTIES_MUST_DECLARE_VISIBILITY, $rules); + $this->assertArrayHasKey(PerPreset::ENUM_CASES_MUST_BE_PASCAL_CASE, $rules); + $this->assertArrayHasKey(PerPreset::ENUM_METHODS_MAY_NOT_BE_PROTECTED, $rules); + $this->assertArrayHasKey(PerPreset::ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED, $rules); + $this->assertArrayHasKey(PerPreset::ANONYMOUS_CLASSES_MAY_NOT_HAVE_EMPTY_PARENTHESES, $rules); + } + + public function testPerPresetUsesComposerSourcePathsByDefault(): void + { + $architecture = Architecture::define(); + + Preset::PER()->apply($architecture); + + $this->assertSame(['Source' => []], $architecture->getLayers()); + $this->assertArrayHasKey( + PerPreset::ENUM_CASES_MUST_BE_PASCAL_CASE, + $architecture->getRules() + ); + $this->assertArrayHasKey( + PerPreset::ENUM_METHODS_MAY_NOT_BE_PROTECTED, + $architecture->getRules() + ); + $this->assertArrayHasKey( + PerPreset::ENUM_CONSTANTS_MAY_NOT_BE_PROTECTED, + $architecture->getRules() + ); + } + public function testPsr4PresetRegistersSourceLayerAndRules(): void { $architecture = Architecture::define(); @@ -218,6 +312,10 @@ public function testDddPresetRegistersAllDefaultRules(): void $this->assertArrayHasKey(DddPreset::VALUE_OBJECT_MUST_BE_FINAL, $rules); $this->assertArrayHasKey(DddPreset::EVENT_MUST_BE_FINAL, $rules); $this->assertArrayHasKey(DddPreset::DOMAIN_NO_JSON_SERIALIZABLE, $rules); + $this->assertInstanceOf( + MayNotExtendClassRule::class, + $rules[DddPreset::DOMAIN_MUST_NOT_EXTEND_DOCTRINE_ENTITY_REPOSITORY] ?? null + ); $this->assertArrayHasKey('ddd.safety.domain_no_dd', $rules); $this->assertArrayHasKey('ddd.safety.application_no_exit', $rules); } @@ -267,7 +365,7 @@ public function testPsr1AndPsr12BothEnabledDoNotDuplicatePsr1Rules(): void $rules = $architecture->getRules(); - $this->assertCount(15, $rules); + $this->assertCount(16, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_TAGS, $rules); $this->assertArrayHasKey(Psr1Preset::FILES_MUST_USE_VALID_UTF8, $rules); @@ -281,6 +379,7 @@ public function testPsr1AndPsr12BothEnabledDoNotDuplicatePsr1Rules(): void $this->assertArrayHasKey(Psr1Preset::CLASSES_MUST_BE_STUDLY_CAPS, $rules); $this->assertArrayHasKey(Psr1Preset::CLASS_CONSTANTS_MUST_BE_UPPER_CASE, $rules); $this->assertArrayHasKey(Psr1Preset::METHODS_MUST_BE_CAMEL_CASE, $rules); + $this->assertArrayHasKey(Psr12Preset::FILES_MUST_USE_LOWERCASE_KEYWORD_CONSTANTS, $rules); $this->assertArrayHasKey(Psr12Preset::METHODS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::CONSTANTS_MUST_DECLARE_VISIBILITY, $rules); $this->assertArrayHasKey(Psr12Preset::PROPERTIES_MUST_DECLARE_VISIBILITY, $rules); @@ -387,6 +486,11 @@ public function testMvcPresetRegistersAllRules(): void ], 'View' => 'src/View/', 'Service' => 'src/Service/', + 'Helper' => [ + 'src/Helper/', + 'src/Helpers/', + 'app/Helpers/', + ], ], $architecture->getLayers() ); @@ -407,6 +511,10 @@ public function testMvcPresetRegistersAllRules(): void 'pattern' => '/(?:^|\\\\)Services?(?:\\\\|$)/', 'excludePattern' => '/(?:^|\\\\)[^\\\\]*Tests?(?:\\\\|$)/', ], + 'Helper' => [ + 'pattern' => '/(?:^|\\\\)Helpers?(?:\\\\|$)/', + 'excludePattern' => '/(?:^|\\\\)[^\\\\]*Tests?(?:\\\\|$)/', + ], ], $architecture->getLayerPatterns()); $rules = $architecture->getRules(); @@ -416,6 +524,7 @@ public function testMvcPresetRegistersAllRules(): void $this->assertArrayHasKey(MvcPreset::MODEL_MUST_HAVE_RETURN_TYPES, $rules); $this->assertArrayHasKey(MvcPreset::VIEW_NO_SUPERGLOBALS, $rules); $this->assertArrayHasKey(MvcPreset::SERVICE_MUST_HAVE_RETURN_TYPES, $rules); + $this->assertArrayHasKey(MvcPreset::HELPER_MUST_HAVE_RETURN_TYPES, $rules); $this->assertArrayHasKey('mvc.safety.controller_no_dd', $rules); $this->assertArrayHasKey('mvc.safety.service_no_exit', $rules); } @@ -438,6 +547,11 @@ public function testMvcPresetDoesNotReplaceConfiguredLayersOrPatterns(): void ], 'View' => 'src/View/', 'Service' => 'src/Service/', + 'Helper' => [ + 'src/Helper/', + 'src/Helpers/', + 'app/Helpers/', + ], ], $architecture->getLayers() ); diff --git a/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php b/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php new file mode 100644 index 00000000..c05d25e5 --- /dev/null +++ b/tests/Rule/Class_/AnonymousClassMayNotHaveEmptyParenthesesRuleTest.php @@ -0,0 +1,251 @@ +assertTrue($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: 'Source') + )); + $this->assertTrue($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: 'Other', layers: ['Other', 'Source']) + )); + $this->assertFalse($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: 'Other') + )); + $this->assertFalse($anonymousClassMayNotHaveEmptyParenthesesRule->appliesTo( + $this->makeNode(layer: null) + )); + } + + public function testPassesAnonymousClassWithoutEmptyParentheses(): void + { + $anonymousClassMayNotHaveEmptyParenthesesRule = new AnonymousClassMayNotHaveEmptyParenthesesRule('Source'); + + $this->assertNotInstanceOf( + RuleViolation::class, + $anonymousClassMayNotHaveEmptyParenthesesRule->evaluate($this->makeNode(hasEmptyParentheses: false)) + ); + } + + public function testFlagsAnonymousClassWithEmptyParentheses(): void + { + $anonymousClassMayNotHaveEmptyParenthesesRule = new AnonymousClassMayNotHaveEmptyParenthesesRule('Source'); + + $violation = $anonymousClassMayNotHaveEmptyParenthesesRule->evaluate( + $this->makeNode(enclosingClassName: 'App\Factory') + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame( + 'Anonymous class in [App\Factory] may not have empty parentheses after `class`', + $violation->message + ); + $this->assertSame('/src/Factory.php', $violation->file); + $this->assertSame(7, $violation->line); + $this->assertSame('App\Factory', $violation->className); + $this->assertSame('Source', $violation->layer); + } + + public function testReportsFileScopeForTopLevelAnonymousClass(): void + { + $anonymousClassMayNotHaveEmptyParenthesesRule = new AnonymousClassMayNotHaveEmptyParenthesesRule('Source'); + + $violation = $anonymousClassMayNotHaveEmptyParenthesesRule->evaluate($this->makeNode()); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame(AnonymousClassNode::FILE_SCOPE, $violation->className); + } + + public function testAnalyseThenFixRemovesOnlyEmptyParentheses(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-anonymous-class-parentheses'); + mkdir($basePath . '/src'); + + $file = $basePath . '/src/Factory.php'; + + file_put_contents($file, <<<'PHP' + layer('Source', 'src/') + ->rule('source.anonymous_classes', new AnonymousClassMayNotHaveEmptyParenthesesRule(layer: 'Source')); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.anonymous_classes'); + + $this->assertSame( + [11, 25, 26, 27, 27, 28, 29], + array_map(static fn (RuleViolation $ruleViolation): int => $ruleViolation->line, $violations) + ); + $this->assertTrue($violations[0]->fixable); + $this->assertSame('App\Factory', $violations[0]->className); + + $rule = $architecture->getRules()['source.anonymous_classes']; + $this->assertInstanceOf(AnonymousClassMayNotHaveEmptyParenthesesRule::class, $rule); + + // The CLI fixes one file's violations in a single parse-and-write cycle. + $this->assertTrue($rule->fix($violations[0], ...array_slice($violations, 1))); + + // Only the empty parentheses are gone: the class bodies, the brace + // placement, and the blank lines are untouched. + $this->assertSame(<<<'PHP' + assertCount( + 0, + (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.anonymous_classes') + ); + $this->assertFalse($rule->fix($violations[0])); + } + + public function testFixLeavesAnonymousClassOnAnotherLineAlone(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-anonymous-class-parentheses-line'); + $file = $basePath . '/Factory.php'; + + file_put_contents($file, <<<'PHP' + assertTrue( + $anonymousClassMayNotHaveEmptyParenthesesRule->fix(new RuleViolation('message', $file, 4, 'file scope')) + ); + + $this->assertSame(<<<'PHP' + $layers */ + private function makeNode( + ?string $layer = 'Source', + ?string $enclosingClassName = null, + bool $hasEmptyParentheses = true, + array $layers = [], + ): AnonymousClassNode { + return new AnonymousClassNode( + file: '/src/Factory.php', + line: 7, + extends: null, + layer: $layer, + enclosingClassName: $enclosingClassName, + hasEmptyParentheses: $hasEmptyParentheses, + layers: $layers, + ); + } +} diff --git a/tests/Rule/Class_/EnumCaseNameMustBePascalCaseRuleTest.php b/tests/Rule/Class_/EnumCaseNameMustBePascalCaseRuleTest.php new file mode 100644 index 00000000..8803fb51 --- /dev/null +++ b/tests/Rule/Class_/EnumCaseNameMustBePascalCaseRuleTest.php @@ -0,0 +1,114 @@ +assertTrue($enumCaseNameMustBePascalCaseRule->appliesTo($this->makeNode([], 'Source'))); + $this->assertFalse($enumCaseNameMustBePascalCaseRule->appliesTo($this->makeNode([], 'Other'))); + $this->assertFalse($enumCaseNameMustBePascalCaseRule->appliesTo( + $this->makeNode([], 'Source', isEnum: false) + )); + } + + public function testEvaluateReturnsFirstViolation(): void + { + $enumCaseNameMustBePascalCaseRule = new EnumCaseNameMustBePascalCaseRule('Source'); + + $violation = $enumCaseNameMustBePascalCaseRule->evaluate( + $this->makeNode([new EnumCaseNode('draft'), new EnumCaseNode('published')]) + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame(1, $violation->line); + } + + #[DataProvider('pascalCaseNameProvider')] + public function testPassesPascalCaseNames(string $name): void + { + $enumCaseNameMustBePascalCaseRule = new EnumCaseNameMustBePascalCaseRule('Source'); + + $this->assertSame( + [], + $enumCaseNameMustBePascalCaseRule->evaluateAll( + $this->makeNode([new EnumCaseNode($name)]) + ) + ); + } + + /** @return iterable */ + public static function pascalCaseNameProvider(): iterable + { + yield 'single word' => ['Draft']; + yield 'multi word' => ['PendingReview']; + yield 'with digits' => ['Http404']; + yield 'abbreviation' => ['XmlExport']; + } + + #[DataProvider('nonPascalCaseNameProvider')] + public function testViolatesNonPascalCaseNames(string $name): void + { + $enumCaseNameMustBePascalCaseRule = new EnumCaseNameMustBePascalCaseRule('Source'); + + $violations = $enumCaseNameMustBePascalCaseRule->evaluateAll( + $this->makeNode([new EnumCaseNode(name: $name, line: 7)]) + ); + + $this->assertCount(1, $violations); + $this->assertInstanceOf(RuleViolation::class, $violations[0]); + $this->assertSame(7, $violations[0]->line); + $this->assertSame( + 'Enum case [App\\Status::' . $name . '] must be declared in PascalCase', + $violations[0]->message + ); + } + + /** @return iterable */ + public static function nonPascalCaseNameProvider(): iterable + { + yield 'camelCase' => ['pendingReview']; + yield 'lower case' => ['draft']; + yield 'UPPER_CASE' => ['PENDING_REVIEW']; + yield 'snake_case' => ['pending_review']; + yield 'underscored' => ['Pending_Review']; + } + + /** + * @param list $enumCases + */ + private function makeNode( + array $enumCases, + string $layer = 'Source', + bool $isEnum = true, + ): ClassNode { + return new ClassNode( + className: 'App\\Status', + file: '/fake.php', + line: 1, + layer: $layer, + extends: null, + isAbstract: false, + isFinal: false, + isInterface: false, + isReadonly: false, + isEnum: $isEnum, + enumCases: $enumCases, + ); + } +} diff --git a/tests/Rule/Class_/EnumConstantMayNotBeProtectedRuleTest.php b/tests/Rule/Class_/EnumConstantMayNotBeProtectedRuleTest.php new file mode 100644 index 00000000..904e8049 --- /dev/null +++ b/tests/Rule/Class_/EnumConstantMayNotBeProtectedRuleTest.php @@ -0,0 +1,157 @@ +assertTrue($enumConstantMayNotBeProtectedRule->appliesTo($this->makeNode([], 'Source'))); + $this->assertFalse($enumConstantMayNotBeProtectedRule->appliesTo($this->makeNode([], 'Other'))); + $this->assertFalse($enumConstantMayNotBeProtectedRule->appliesTo( + $this->makeNode([], 'Source', isEnum: false) + )); + } + + public function testEvaluateReturnsFirstViolation(): void + { + $enumConstantMayNotBeProtectedRule = new EnumConstantMayNotBeProtectedRule('Source'); + + $violation = $enumConstantMayNotBeProtectedRule->evaluate( + $this->makeNode([ + new ConstantNode('Grey', 'protected', hasExplicitVisibility: true), + new ConstantNode('Blue', 'protected', hasExplicitVisibility: true), + ]) + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame(1, $violation->line); + } + + #[DataProvider('allowedVisibilityProvider')] + public function testPassesNonProtectedConstants(string $visibility): void + { + $enumConstantMayNotBeProtectedRule = new EnumConstantMayNotBeProtectedRule('Source'); + + $this->assertSame( + [], + $enumConstantMayNotBeProtectedRule->evaluateAll( + $this->makeNode([new ConstantNode('Grey', $visibility, hasExplicitVisibility: true)]) + ) + ); + } + + /** @return iterable */ + public static function allowedVisibilityProvider(): iterable + { + yield 'public' => ['public']; + yield 'private' => ['private']; + } + + public function testViolatesProtectedConstants(): void + { + $enumConstantMayNotBeProtectedRule = new EnumConstantMayNotBeProtectedRule('Source'); + + $violations = $enumConstantMayNotBeProtectedRule->evaluateAll( + $this->makeNode([ + new ConstantNode('Grey', 'public', hasExplicitVisibility: true, line: 5), + new ConstantNode('Blue', 'protected', hasExplicitVisibility: true, line: 9), + new ConstantNode('Red', 'protected', hasExplicitVisibility: true, line: 13), + ]) + ); + + $this->assertCount(2, $violations); + $this->assertInstanceOf(RuleViolation::class, $violations[0]); + $this->assertSame(9, $violations[0]->line); + $this->assertSame( + 'Enum constant [App\\Status::Blue] may not be declared protected, use private instead', + $violations[0]->message + ); + $this->assertSame('Blue', $violations[0]->constantName); + $this->assertSame(13, $violations[1]->line); + } + + public function testFixChangesProtectedConstantToPrivate(): void + { + $file = tempnam(sys_get_temp_dir(), 'structarmed-enum-constant-'); + $this->assertIsString($file); + + file_put_contents($file, <<<'PHP' +assertTrue($enumConstantMayNotBeProtectedRule->fix(new RuleViolation( + message: 'Enum constant [Status::Grey] may not be declared protected, use private instead', + file: $file, + line: 5, + className: 'Status', + constantName: 'Grey', + ))); + + $this->assertStringContainsString( + " private const Grey = 'grey';", + (string) file_get_contents($file) + ); + } finally { + unlink($file); + } + } + + /** + * @param list $constants + */ + private function makeNode( + array $constants, + string $layer = 'Source', + bool $isEnum = true, + ): ClassNode { + return new ClassNode( + className: 'App\\Status', + file: '/fake.php', + line: 1, + layer: $layer, + extends: null, + isAbstract: false, + isFinal: false, + isInterface: false, + isReadonly: false, + constants: $constants, + isEnum: $isEnum, + ); + } +} diff --git a/tests/Rule/Class_/EnumMethodMayNotBeProtectedRuleTest.php b/tests/Rule/Class_/EnumMethodMayNotBeProtectedRuleTest.php new file mode 100644 index 00000000..c10315bb --- /dev/null +++ b/tests/Rule/Class_/EnumMethodMayNotBeProtectedRuleTest.php @@ -0,0 +1,175 @@ +assertTrue($enumMethodMayNotBeProtectedRule->appliesTo($this->makeNode([], 'Source'))); + $this->assertFalse($enumMethodMayNotBeProtectedRule->appliesTo($this->makeNode([], 'Other'))); + $this->assertFalse($enumMethodMayNotBeProtectedRule->appliesTo( + $this->makeNode([], 'Source', isEnum: false) + )); + } + + public function testEvaluateReturnsFirstViolation(): void + { + $enumMethodMayNotBeProtectedRule = new EnumMethodMayNotBeProtectedRule('Source'); + + $violation = $enumMethodMayNotBeProtectedRule->evaluate( + $this->makeNode([ + $this->makeMethod('label', 'protected'), + $this->makeMethod('color', 'protected'), + ]) + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame(1, $violation->line); + } + + #[DataProvider('allowedVisibilityProvider')] + public function testPassesNonProtectedMethods(string $visibility): void + { + $enumMethodMayNotBeProtectedRule = new EnumMethodMayNotBeProtectedRule('Source'); + + $this->assertSame( + [], + $enumMethodMayNotBeProtectedRule->evaluateAll( + $this->makeNode([$this->makeMethod('label', $visibility)]) + ) + ); + } + + /** @return iterable */ + public static function allowedVisibilityProvider(): iterable + { + yield 'public' => ['public']; + yield 'private' => ['private']; + } + + public function testViolatesProtectedMethods(): void + { + $enumMethodMayNotBeProtectedRule = new EnumMethodMayNotBeProtectedRule('Source'); + + $violations = $enumMethodMayNotBeProtectedRule->evaluateAll( + $this->makeNode([ + $this->makeMethod('label', 'public', line: 5), + $this->makeMethod('color', 'protected', line: 9), + $this->makeMethod('icon', 'protected', line: 13), + ]) + ); + + $this->assertCount(2, $violations); + $this->assertInstanceOf(RuleViolation::class, $violations[0]); + $this->assertSame(9, $violations[0]->line); + $this->assertSame( + 'Enum method [App\\Status::color] may not be declared protected, use private instead', + $violations[0]->message + ); + $this->assertSame('color', $violations[0]->methodName); + $this->assertSame(13, $violations[1]->line); + } + + public function testFixChangesProtectedMethodToPrivate(): void + { + $file = tempnam(sys_get_temp_dir(), 'structarmed-enum-method-'); + $this->assertIsString($file); + + file_put_contents($file, <<<'PHP' +assertTrue($enumMethodMayNotBeProtectedRule->fix(new RuleViolation( + message: 'Enum method [Status::color] may not be declared protected, use private instead', + file: $file, + line: 7, + className: 'Status', + methodName: 'color', + ))); + + $this->assertStringContainsString( + ' private static function color(): string', + (string) file_get_contents($file) + ); + } finally { + unlink($file); + } + } + + private function makeMethod(string $name, string $visibility, int $line = 0): MethodNode + { + return new MethodNode( + name: $name, + visibility: $visibility, + hasReturnType: true, + isStatic: false, + paramCount: 0, + cyclomaticComplexity: 1, + lineCount: 3, + hasExplicitVisibility: true, + line: $line, + ); + } + + /** + * @param list $methods + */ + private function makeNode( + array $methods, + string $layer = 'Source', + bool $isEnum = true, + ): ClassNode { + return new ClassNode( + className: 'App\\Status', + file: '/fake.php', + line: 1, + layer: $layer, + extends: null, + isAbstract: false, + isFinal: false, + isInterface: false, + isReadonly: false, + methods: $methods, + isEnum: $isEnum, + ); + } +} diff --git a/tests/Rule/Class_/MayNotExtendClassRuleTest.php b/tests/Rule/Class_/MayNotExtendClassRuleTest.php new file mode 100644 index 00000000..708cd2aa --- /dev/null +++ b/tests/Rule/Class_/MayNotExtendClassRuleTest.php @@ -0,0 +1,116 @@ +assertNotInstanceOf(RuleViolation::class, $mayNotExtendClassRule->evaluate($this->makeNode(null))); + } + + public function testPassesWhenAnotherClassIsExtended(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: self::MODEL); + + $this->assertNotInstanceOf( + RuleViolation::class, + $mayNotExtendClassRule->evaluate($this->makeNode('App\\Domain\\AbstractEntity')) + ); + } + + public function testAppliesOnlyToConfiguredLayer(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: self::MODEL); + + $this->assertTrue($mayNotExtendClassRule->appliesTo($this->makeNode(null))); + $this->assertFalse($mayNotExtendClassRule->appliesTo($this->makeNode(null, 'Infrastructure'))); + } + + public function testAppliesOnlyToClasses(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: self::MODEL); + + $this->assertFalse($mayNotExtendClassRule->appliesTo($this->makeNode(null, isInterface: true))); + $this->assertFalse($mayNotExtendClassRule->appliesTo($this->makeNode(null, isTrait: true))); + $this->assertFalse($mayNotExtendClassRule->appliesTo($this->makeNode(null, isEnum: true))); + } + + public function testViolatesWhenClassDirectlyExtendsForbiddenClass(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: self::MODEL); + + $violation = $mayNotExtendClassRule->evaluate($this->makeNode(self::MODEL)); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame( + sprintf('Class [App\\Domain\\Order] must not extend class [%s]', self::MODEL), + $violation->message + ); + } + + public function testMatchesClassNameCaseInsensitively(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: strtolower(self::MODEL)); + + $this->assertInstanceOf( + RuleViolation::class, + $mayNotExtendClassRule->evaluate($this->makeNode(self::MODEL)) + ); + } + + public function testViolatesWhenClassIndirectlyExtendsForbiddenClass(): void + { + $mayNotExtendClassRule = new MayNotExtendClassRule(layer: 'Domain', class: self::MODEL); + + // App\Domain\Order extends App\Domain\Entity, which extends the ORM model. + $classNode = $this->makeNode('App\\Domain\\Entity'); + $classNode->setRecursiveParents(['App\\Domain\\Entity', self::MODEL], []); + + $violation = $mayNotExtendClassRule->evaluate($classNode); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame( + sprintf('Class [App\\Domain\\Order] must not extend class [%s]', self::MODEL), + $violation->message + ); + } + + private function makeNode( + ?string $extends, + string $layer = 'Domain', + bool $isInterface = false, + bool $isTrait = false, + bool $isEnum = false, + ): ClassNode { + return new ClassNode( + className: 'App\\Domain\\Order', + file: '/fake.php', + line: 1, + layer: $layer, + extends: $extends, + isAbstract: false, + isFinal: false, + isInterface: $isInterface, + isReadonly: false, + isTrait: $isTrait, + isEnum: $isEnum, + ); + } +} diff --git a/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php b/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php index 152db619..09445443 100644 --- a/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php +++ b/tests/Rule/Class_/MustBeUsedInterfaceRuleFixTest.php @@ -48,6 +48,77 @@ className: 'App\\UnusedInterface', $this->assertFileDoesNotExist($file); } + public function testBatchFixRunsEveryVisitorBeforeDeletingFile(): void + { + $temporaryDirectory = $this->makeTemporaryDirectory('structarmed-yagni-interface'); + $file = $temporaryDirectory . '/UnusedInterfaces.php'; + + file_put_contents( + $file, + "assertTrue($mustBeUsedInterfaceRule->fix( + new RuleViolation( + message: 'Interface [App\\FirstUnused] must be used', + file: $file, + line: 5, + className: 'App\\FirstUnused', + layer: 'Domain', + ), + new RuleViolation( + message: 'Interface [App\\SecondUnused] must be used', + file: $file, + line: 9, + className: 'App\\SecondUnused', + layer: 'Domain', + ), + )); + $this->assertFileDoesNotExist($file); + + // A later fixer batch stops at the processor's is_file() guard. + $this->assertFalse($mustBeUsedInterfaceRule->fix(new RuleViolation( + message: 'Interface [App\\FirstUnused] must be used', + file: $file, + line: 5, + className: 'App\\FirstUnused', + layer: 'Domain', + ))); + } + + public function testBatchFixRejectsViolationsFromDifferentFiles(): void + { + $temporaryDirectory = $this->makeTemporaryDirectory('structarmed-yagni-interface'); + $firstFile = $temporaryDirectory . '/FirstUnused.php'; + $secondFile = $temporaryDirectory . '/SecondUnused.php'; + + file_put_contents($firstFile, "assertFalse($mustBeUsedInterfaceRule->fix( + new RuleViolation( + message: 'Interface [FirstUnused] must be used', + file: $firstFile, + line: 3, + className: 'FirstUnused', + layer: 'Domain', + ), + new RuleViolation( + message: 'Interface [SecondUnused] must be used', + file: $secondFile, + line: 3, + className: 'SecondUnused', + layer: 'Domain', + ), + )); + $this->assertFileExists($firstFile); + $this->assertFileExists($secondFile); + } + public function testFixKeepsFileWhenDeclareBlockContainsExecutableCode(): void { $temporaryDirectory = $this->makeTemporaryDirectory('structarmed-yagni-interface'); diff --git a/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php b/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php index 10929a3f..c7b9152f 100644 --- a/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php +++ b/tests/Rule/Composer/Psr4ComposerFilePathNormalisationTest.php @@ -17,25 +17,25 @@ final class Psr4ComposerFilePathNormalisationTest extends TestCase { private const WINDOWS_STYLE_MISSING_BASE_PATH = 'C:\structarmed-missing-fixture\app'; - public function testDirectoryExistsRuleReportsForwardSlashesForWindowsStyleBasePath(): void + public function testDirectoryExistsRulePassesWhenComposerJsonIsMissingAtWindowsStyleBasePath(): void { - $violation = (new Psr4DirectoryExistsRule())->evaluateProject( - self::WINDOWS_STYLE_MISSING_BASE_PATH, - Architecture::define() + $this->assertNotInstanceOf( + RuleViolation::class, + (new Psr4DirectoryExistsRule())->evaluateProject( + self::WINDOWS_STYLE_MISSING_BASE_PATH, + Architecture::define() + ) ); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertSame('C:/structarmed-missing-fixture/app/composer.json', $violation->file); } - public function testSourcePathsRuleReportsForwardSlashesForWindowsStyleBasePath(): void + public function testSourcePathsRulePassesWhenComposerJsonIsMissingAtWindowsStyleBasePath(): void { - $violation = (new Psr4SourcePathsRule(null))->evaluateProject( - self::WINDOWS_STYLE_MISSING_BASE_PATH, - Architecture::define() + $this->assertNotInstanceOf( + RuleViolation::class, + (new Psr4SourcePathsRule(null))->evaluateProject( + self::WINDOWS_STYLE_MISSING_BASE_PATH, + Architecture::define() + ) ); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertSame('C:/structarmed-missing-fixture/app/composer.json', $violation->file); } } diff --git a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php index 68b6cce0..5137ca83 100644 --- a/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php +++ b/tests/Rule/Composer/Psr4DirectoryExistsRuleTest.php @@ -153,23 +153,23 @@ public function testFailsWhenPsr4PathIsAbsoluteAndDoesNotExistOnDisk(): void $this->assertStringContainsString('do not exist on disk', $violation->message); } - public function testFailsWhenComposerJsonIsMissing(): void + public function testPassesWhenComposerJsonIsMissing(): void { - $violation = (new Psr4DirectoryExistsRule())->evaluateProject($this->makeTempDir(), Architecture::define()); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertStringContainsString('composer.json was not found', $violation->message); + $this->assertNotInstanceOf( + RuleViolation::class, + (new Psr4DirectoryExistsRule())->evaluateProject($this->makeTempDir(), Architecture::define()) + ); } - public function testFailsWhenComposerJsonIsInvalid(): void + public function testPassesWhenComposerJsonIsInvalid(): void { - $violation = (new Psr4DirectoryExistsRule())->evaluateProject( - $this->makeTempProject('{not json'), - Architecture::define() + $this->assertNotInstanceOf( + RuleViolation::class, + (new Psr4DirectoryExistsRule())->evaluateProject( + $this->makeTempProject('{not json'), + Architecture::define() + ) ); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertStringContainsString('composer.json is not valid JSON', $violation->message); } public function testPassesWhenNoPsr4PathsAreDeclared(): void @@ -223,10 +223,41 @@ public function testFixRemovesPsr4MappingsForMissingDirectories(): void } } JSON, file_get_contents($basePath . '/composer.json')); - $this->assertNotInstanceOf( - RuleViolation::class, - $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()) - ); + + $batchBasePath = $this->makeTempProject(<<<'JSON' +{ + "autoload": { + "psr-4": { + "Missing\\": "missing/" + } + } +} +JSON); + $batchViolation = $psr4DirectoryExistsRule->evaluateProject($batchBasePath, Architecture::define()); + + $this->assertInstanceOf(RuleViolation::class, $batchViolation); + $this->assertTrue($psr4DirectoryExistsRule->fix($batchViolation, $batchViolation)); + $this->assertSame("{\n}", file_get_contents($batchBasePath . '/composer.json')); + + $firstBasePath = $this->makeTempProject('{}'); + $secondBasePath = $this->makeTempProject('{}'); + + $this->assertFalse($psr4DirectoryExistsRule->fix( + new RuleViolation( + message: 'First violation', + file: $firstBasePath . '/composer.json', + line: 1, + className: '', + ), + new RuleViolation( + message: 'Second violation', + file: $secondBasePath . '/composer.json', + line: 1, + className: '', + ), + )); + $this->assertSame('{}', file_get_contents($firstBasePath . '/composer.json')); + $this->assertSame('{}', file_get_contents($secondBasePath . '/composer.json')); } public function testFixRemovesPsr4BlockWhenEveryMappingDirectoryIsMissing(): void @@ -250,10 +281,6 @@ public function testFixRemovesPsr4BlockWhenEveryMappingDirectoryIsMissing(): voi { } JSON, file_get_contents($basePath . '/composer.json')); - $this->assertNotInstanceOf( - RuleViolation::class, - $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()) - ); } public function testFixKeepsUnchangedEmptyPsr4Block(): void @@ -285,10 +312,6 @@ public function testFixKeepsUnchangedEmptyPsr4Block(): void } } JSON, file_get_contents($basePath . '/composer.json')); - $this->assertNotInstanceOf( - RuleViolation::class, - $psr4DirectoryExistsRule->evaluateProject($basePath, Architecture::define()) - ); } public function testFixReturnsFalseWhenAllPsr4DirectoriesExist(): void diff --git a/tests/Rule/Composer/Psr4SourcePathsRuleTest.php b/tests/Rule/Composer/Psr4SourcePathsRuleTest.php index 34be7cd7..3ae7840a 100644 --- a/tests/Rule/Composer/Psr4SourcePathsRuleTest.php +++ b/tests/Rule/Composer/Psr4SourcePathsRuleTest.php @@ -66,27 +66,27 @@ public function testFailsWhenSourcePathIsMissingFromComposerPsr4Autoloads(): voi $this->assertStringContainsString('tests', $violation->message); } - public function testFailsWhenComposerJsonIsMissing(): void + public function testPassesWhenComposerJsonIsMissing(): void { $psr4SourcePathsRule = new Psr4SourcePathsRule(['src/']); - $violation = $psr4SourcePathsRule->evaluateProject($this->makeTempDir(), Architecture::define()); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertStringContainsString('composer.json was not found', $violation->message); + $this->assertNotInstanceOf( + RuleViolation::class, + $psr4SourcePathsRule->evaluateProject($this->makeTempDir(), Architecture::define()) + ); } - public function testFailsWhenComposerJsonIsInvalid(): void + public function testPassesWhenComposerJsonIsInvalid(): void { $psr4SourcePathsRule = new Psr4SourcePathsRule(['src/']); - $violation = $psr4SourcePathsRule->evaluateProject( - $this->makeTempProject('{not json'), - Architecture::define() + $this->assertNotInstanceOf( + RuleViolation::class, + $psr4SourcePathsRule->evaluateProject( + $this->makeTempProject('{not json'), + Architecture::define() + ) ); - - $this->assertInstanceOf(RuleViolation::class, $violation); - $this->assertStringContainsString('composer.json is not valid JSON', $violation->message); } public function testPassesWhenComposerPsr4MappingUsesPathList(): void diff --git a/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php new file mode 100644 index 00000000..0754187b --- /dev/null +++ b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleTest.php @@ -0,0 +1,153 @@ +makeProject(<<<'PHP' +evaluateProjectAll( + $basePath, + Architecture::define(), + ); + + $this->assertCount(3, $violations); + + foreach ($violations as $violation) { + $this->assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violation)); + } + + $this->assertSame(<<<'PHP' +assertSame( + [], + $largeNumericLiteralMustUseSeparatorRule->evaluateProjectAll($basePath, Architecture::define()), + ); + } + + public function testFixTargetsTheLiteralSpellingOnItsLine(): void + { + $basePath = $this->makeProject("evaluateProjectAll( + $basePath, + Architecture::define(), + ); + + $this->assertCount(2, $violations); + $this->assertEquals( + $violations[0], + $largeNumericLiteralMustUseSeparatorRule->evaluateProject($basePath, Architecture::define()), + ); + $this->assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violations[1])); + $this->assertSame( + "assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violations[0])); + $this->assertSame( + "makeProject("assertTrue((new PhpParserFixerProcessor())->process($basePath . '/src/Foo.php', $visitor)); + $this->assertSame("makeProject("layer('Source', 'src/') + ->rule('numeric.separator', $largeNumericLiteralMustUseSeparatorRule); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $options) { + $violations = array_values(iterator_to_array( + (new Analyser($basePath))->analyse($architecture, [], null, $options), + )); + + $this->assertCount(1, $violations); + $this->assertSame('numeric.separator', $violations[0]->ruleKey); + $this->assertTrue($violations[0]->fixable); + $this->assertSame('1000000', $violations[0]->numericLiteral); + } + + $this->assertTrue($largeNumericLiteralMustUseSeparatorRule->fix($violations[0])); + $this->assertSame("makeTemporaryDirectory('structarmed-numeric-separator'); + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', $code); + + $realBasePath = realpath($basePath); + $this->assertIsString($realBasePath); + + return Path::normalise($realBasePath, canonicalise: true); + } +} diff --git a/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleUnitTest.php b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleUnitTest.php new file mode 100644 index 00000000..56145576 --- /dev/null +++ b/tests/Rule/File/LargeNumericLiteralMustUseSeparatorRuleUnitTest.php @@ -0,0 +1,181 @@ +evaluate( + [[3, '1000000', 1000000], [4, '10000000', 10000000], [5, '100000000', 100000000]], + largeNumericLiteralMustUseSeparatorRule: $largeNumericLiteralMustUseSeparatorRule, + ); + + $this->assertInstanceOf(FixableInterface::class, $largeNumericLiteralMustUseSeparatorRule); + $this->assertSame( + [ + 'Numeric literal [1000000] must use separator formatting [1_000_000]', + 'Numeric literal [10000000] must use separator formatting [10_000_000]', + 'Numeric literal [100000000] must use separator formatting [100_000_000]', + ], + array_map(static fn (RuleViolation $ruleViolation): string => $ruleViolation->message, $violations), + ); + $this->assertSame( + [3, 4, 5], + array_map(static fn (RuleViolation $ruleViolation): int => $ruleViolation->line, $violations), + ); + $this->assertSame( + ['1000000', '10000000', '100000000'], + array_map( + static fn (RuleViolation $ruleViolation): ?string => $ruleViolation->numericLiteral, + $violations, + ), + ); + } + + public function testIgnoresBelowThresholdAndAlreadySeparatedIntegers(): void + { + $this->assertSame([], $this->evaluate([ + [3, '999999', 999999], + [4, '1_000_000', 1000000], + [5, '10_000_000', 10000000], + [6, '999999.99', 999999.99], + [7, '1_000_000.0', 1000000.0], + ])); + } + + public function testReportsPlainDecimalFloatsAndPreservesTheirFractionalParts(): void + { + $violations = $this->evaluate([ + [3, '1000000.0', 1000000.0], + [4, '10000000.0', 10000000.0], + [5, '1000500.001', 1000500.001], + ]); + + $this->assertSame( + [ + 'Numeric literal [1000000.0] must use separator formatting [1_000_000.0]', + 'Numeric literal [10000000.0] must use separator formatting [10_000_000.0]', + 'Numeric literal [1000500.001] must use separator formatting [1_000_500.001]', + ], + array_map(static fn (RuleViolation $ruleViolation): string => $ruleViolation->message, $violations), + ); + } + + public function testSupportsCustomMinimum(): void + { + $violations = $this->evaluate( + [[3, '1000', 1000]], + new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000, sourcePaths: ['src/']), + ); + + $this->assertCount(1, $violations); + $this->assertSame( + 'Numeric literal [1000] must use separator formatting [1_000]', + $violations[0]->message, + ); + } + + public function testRejectsNonPositiveMinimum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The minimum must be a positive integer.'); + + new LargeNumericLiteralMustUseSeparatorRule(minimum: 0); + } + + public function testIgnoresUnsupportedNumericSyntaxesAndFloats(): void + { + $this->assertSame([], $this->evaluate([ + [3, '0xFFFFFF', 16777215], + [4, '0b11111111', 255], + [5, '0o755', 493], + [6, '077777', 32767], + [7, '1e10', 10000000000.0], + [8, '1.2e6', 1200000.0], + ])); + } + + public function testUsesTheLiteralMagnitudeCollectedInsideAUnaryMinus(): void + { + $violations = $this->evaluate([[3, '1000000', 1000000]]); + + $this->assertCount(1, $violations); + $this->assertSame('1000000', $violations[0]->numericLiteral); + } + + public function testVisitorWithoutLiteralPayloadDoesNothing(): void + { + $int = Int_::fromString('10000', ['startLine' => 3]); + $addNumericLiteralSeparatorsVisitor = new AddNumericLiteralSeparatorsVisitor(3, null, null); + + $this->assertNotInstanceOf(Node::class, $addNumericLiteralSeparatorsVisitor->enterNode($int)); + $this->assertSame('10000', $int->getAttribute('rawValue')); + } + + public function testDoesNotReportAValueTooShortToContainASeparator(): void + { + $this->assertSame( + [], + $this->evaluate( + [[3, '1', 1]], + new LargeNumericLiteralMustUseSeparatorRule(minimum: 1, sourcePaths: ['src/']), + ), + ); + } + + /** + * @param list $numericLiterals + * @return list + */ + private function evaluate( + array $numericLiterals, + ?LargeNumericLiteralMustUseSeparatorRule $largeNumericLiteralMustUseSeparatorRule = null, + ): array { + $fileAnalysis = new FileAnalysis( + file: self::FILE, + hasUtf8Bom: false, + hasValidUtf8: true, + invalidPhpTagLine: null, + hasValidAst: true, + declaresSymbols: false, + hasSideEffects: true, + sideEffectLine: 3, + numericLiterals: $numericLiterals, + ); + $fileAnalysisProvider = FileAnalysisProvider::forScope( + [self::FILE => $fileAnalysis], + [self::FILE], + ); + + return ($largeNumericLiteralMustUseSeparatorRule + ?? new LargeNumericLiteralMustUseSeparatorRule(sourcePaths: ['src/'])) + ->evaluateProjectAllWithProvider(self::BASE_PATH, Architecture::define(), $fileAnalysisProvider); + } +} diff --git a/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php b/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php new file mode 100644 index 00000000..dc598952 --- /dev/null +++ b/tests/Rule/File/MustUseLowercaseKeywordConstantRuleTest.php @@ -0,0 +1,488 @@ +assertInstanceOf(FixableInterface::class, new MustUseLowercaseKeywordConstantRule(['src/'])); + } + + #[DataProvider('unqualifiedSpellingProvider')] + public function testViolatesAndFixesUnqualifiedSpelling(string $spelling): void + { + $basePath = $this->makeProject("evaluateProjectAll($basePath, Architecture::define()); + + $this->assertCount(1, $violations); + $this->assertSame(3, $violations[0]->line); + $this->assertSame($basePath . '/src/Foo.php', $violations[0]->file); + $this->assertSame( + 'Keyword constant [' . $spelling . '] must use lowercase [' . strtolower($spelling) . ']', + $violations[0]->message + ); + $this->assertSame($spelling, $violations[0]->constantName); + + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violations[0])); + $this->assertSame( + "assertSame( + [], + $mustUseLowercaseKeywordConstantRule->evaluateProjectAll($basePath, Architecture::define()) + ); + } + + /** @return iterable */ + public static function unqualifiedSpellingProvider(): iterable + { + foreach (['TRUE', 'True', 'tRuE', 'FALSE', 'False', 'fAlSe', 'NULL', 'Null', 'nUlL'] as $spelling) { + yield $spelling => [$spelling]; + } + } + + #[DataProvider('unqualifiedSpellingProvider')] + public function testViolatesAndFixesFullyQualifiedSpellingKeepingTheLeadingBackslash(string $spelling): void + { + $basePath = $this->makeProject("evaluateProjectAll($basePath, Architecture::define()); + + $this->assertCount(1, $violations); + $this->assertSame(3, $violations[0]->line); + $this->assertSame( + 'Keyword constant [\\' . $spelling . '] must use lowercase [\\' . strtolower($spelling) . ']', + $violations[0]->message + ); + $this->assertSame($spelling, $violations[0]->constantName); + + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violations[0])); + $this->assertSame( + "makeProject(<<<'PHP' +assertSame( + [], + $mustUseLowercaseKeywordConstantRule->evaluateProjectAll($basePath, Architecture::define()) + ); + $this->assertNotInstanceOf( + RuleViolation::class, + $mustUseLowercaseKeywordConstantRule->evaluateProject($basePath, Architecture::define()) + ); + } + + public function testIgnoresUnrelatedConstants(): void + { + $basePath = $this->makeProject(<<<'PHP' +evaluateProjectAll($basePath, Architecture::define()); + + $this->assertSame([], $violations); + $this->assertStringContainsString('SomeClass::FOO;', (string) file_get_contents($basePath . '/src/Foo.php')); + } + + public function testReportsUnqualifiedSpellingInsideNamespace(): void + { + $basePath = $this->makeProject(<<<'PHP' +evaluateProjectAll($basePath, Architecture::define()); + + $this->assertCount(1, $violations); + $this->assertSame(7, $violations[0]->line); + $this->assertSame('Keyword constant [TRUE] must use lowercase [true]', $violations[0]->message); + } + + public function testSkipsRelativeKeywordLikeConstant(): void + { + $basePath = $this->makeProject(<<<'PHP' +assertSame( + [], + $mustUseLowercaseKeywordConstantRule->evaluateProjectAll($basePath, Architecture::define()) + ); + } + + public function testLeavesRelativeKeywordLikeConstantAloneWhenFixingTheSameLine(): void + { + // namespace\TRUE names the case-sensitive constant Foo\TRUE, not the keyword. + $basePath = $this->makeProject(<<<'PHP' +evaluateProjectAll( + $basePath, + Architecture::define() + ); + + $this->assertCount(1, $violations); + $this->assertSame('Keyword constant [TRUE] must use lowercase [true]', $violations[0]->message); + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violations[0])); + $this->assertSame(<<<'PHP' +makeProject(<<<'PHP' +evaluateProjectAll( + $basePath, + Architecture::define() + ); + + $this->assertSame( + [ + 'Keyword constant [NULL] must use lowercase [null]', + 'Keyword constant [FALSE] must use lowercase [false]', + 'Keyword constant [\\TRUE] must use lowercase [\\true]', + 'Keyword constant [\\NULL] must use lowercase [\\null]', + 'Keyword constant [\\FALSE] must use lowercase [\\false]', + 'Keyword constant [TRUE] must use lowercase [true]', + ], + array_map(static fn (RuleViolation $ruleViolation): string => $ruleViolation->message, $violations) + ); + $this->assertSame([6, 8, 10, 10, 10, 12], array_map( + static fn (RuleViolation $ruleViolation): int => $ruleViolation->line, + $violations + )); + + foreach ($violations as $violation) { + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violation)); + } + + $this->assertSame(<<<'PHP' +makeProject(<<<'PHP' +evaluateProjectAll( + $basePath, + Architecture::define() + ); + + $this->assertCount(3, $violations); + + foreach ($violations as $violation) { + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violation)); + } + + $this->assertSame(<<<'PHP' +assertSame( + [], + $mustUseLowercaseKeywordConstantRule->evaluateProjectAll($basePath, Architecture::define()) + ); + } + + public function testFixesIdenticalSpellingsOnTheSameLineInOnePass(): void + { + $basePath = $this->makeProject("evaluateProjectAll( + $basePath, + Architecture::define() + ); + + $this->assertCount(2, $violations); + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violations[0])); + $this->assertSame("assertFalse($mustUseLowercaseKeywordConstantRule->fix($violations[1])); + } + + public function testSkipsFilesWithParseErrors(): void + { + $basePath = $this->makeProject('evaluateProjectAll($basePath, Architecture::define()); + + $this->assertSame([], $violations); + } + + public function testEvaluatesProvidedFileAnalysesWithinSourcePaths(): void + { + $basePath = $this->makeProject(" $this->makeFileAnalysis($sourceFile, [[3, '\\TRUE'], [9, 'Null']]), + $testFile => $this->makeFileAnalysis($testFile, [[3, 'NULL']]), + ], + [$sourceFile, $testFile], + ); + + $violations = (new MustUseLowercaseKeywordConstantRule(['src/']))->evaluateProjectAllWithProvider( + $basePath, + Architecture::define(), + $fileAnalysisProvider, + ); + + $this->assertCount(2, $violations); + $this->assertSame($sourceFile, $violations[0]->file); + $this->assertSame(3, $violations[0]->line); + $this->assertSame('Keyword constant [\\TRUE] must use lowercase [\\true]', $violations[0]->message); + $this->assertSame('TRUE', $violations[0]->constantName); + $this->assertSame(9, $violations[1]->line); + $this->assertSame('Null', $violations[1]->constantName); + } + + public function testFixWithoutConstantNameDoesNothing(): void + { + $basePath = $this->makeProject("assertFalse($mustUseLowercaseKeywordConstantRule->fix(new RuleViolation( + message: 'Keyword constant [TRUE] must use lowercase [true]', + file: $basePath . '/src/Foo.php', + line: 3, + className: '', + ))); + $this->assertSame( + "assertFalse($mustUseLowercaseKeywordConstantRule->fix(new RuleViolation( + message: 'Keyword constant [TRUE] must use lowercase [true]', + file: '/missing/Foo.php', + line: 1, + className: '', + ))); + } + + public function testAnalyserReportsAndFixesThroughTheCollectedFileAnalysis(): void + { + $basePath = $this->makeProject(<<<'PHP' +layer('Source', 'src/') + ->rule('keyword.lowercase', $mustUseLowercaseKeywordConstantRule); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $analyserOptions) { + $violations = array_values(iterator_to_array( + (new Analyser($basePath))->analyse($architecture, [], null, $analyserOptions) + )); + + $this->assertCount(2, $violations); + $this->assertSame('keyword.lowercase', $violations[0]->ruleKey); + $this->assertTrue($violations[0]->fixable); + $this->assertSame(9, $violations[0]->line); + $this->assertSame('Keyword constant [\\TRUE] must use lowercase [\\true]', $violations[0]->message); + $this->assertSame('Keyword constant [Null] must use lowercase [null]', $violations[1]->message); + } + + foreach ($violations as $violation) { + $this->assertTrue($mustUseLowercaseKeywordConstantRule->fix($violation)); + } + + $this->assertStringContainsString( + 'return \true ? null : false;', + (string) file_get_contents($basePath . '/src/Foo.php') + ); + $this->assertCount( + 0, + (new Analyser($basePath))->analyse($architecture, [], null, AnalyserOptions::sequential()) + ); + } + + private function makeProject(string $code): string + { + $basePath = $this->makeTemporaryDirectory('structarmed-keyword-constant'); + mkdir($basePath . '/src'); + file_put_contents($basePath . '/src/Foo.php', $code); + + // Violations carry canonical, forward-slash paths: realpath() differs from the + // temporary directory on macOS and uses backslashes on Windows. + $realBasePath = realpath($basePath); + $this->assertIsString($realBasePath); + + return Path::normalise($realBasePath, canonicalise: true); + } + + /** @param list $nonCanonicalKeywordConstants */ + private function makeFileAnalysis(string $file, array $nonCanonicalKeywordConstants): FileAnalysis + { + return new FileAnalysis( + file: $file, + hasUtf8Bom: false, + hasValidUtf8: true, + invalidPhpTagLine: null, + hasValidAst: true, + declaresSymbols: false, + hasSideEffects: true, + sideEffectLine: 3, + nonCanonicalKeywordConstants: $nonCanonicalKeywordConstants, + ); + } +} diff --git a/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php b/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php index ed85f322..7bfcdc1b 100644 --- a/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php +++ b/tests/Rule/Fixer/JsonRecast/JsonRecastFixerProcessorTest.php @@ -4,10 +4,13 @@ namespace Boundwize\StructArmed\Tests\Rule\Fixer\JsonRecast; +use Boundwize\JsonRecast\Node\NodeJson; +use Boundwize\JsonRecast\Node\StringNode; use Boundwize\JsonRecast\NodeVisitor\NodeJsonVisitorAbstract; use Boundwize\StructArmed\Rule\Fixer\JsonRecast\JsonRecastFixerProcessor; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; use function file_put_contents; use function sys_get_temp_dir; @@ -17,12 +20,31 @@ #[CoversClass(JsonRecastFixerProcessor::class)] final class JsonRecastFixerProcessorTest extends TestCase { - public function testProcessReturnsFalseWhenFileDoesNotExist(): void + public function testProcessHandlesUnavailableFileAndInvalidTraversalRoot(): void { $file = $this->temporaryJsonFile('{}'); unlink($file); $this->assertFalse($this->process($file)); + + $file = $this->temporaryJsonFile('{}'); + + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('JsonRecast fixer traversal must return JsonDocument.'); + + (new JsonRecastFixerProcessor())->process( + $file, + new class extends NodeJsonVisitorAbstract { + public function beforeTraverse(NodeJson $nodeJson): StringNode + { + return new StringNode('replacement'); + } + }, + ); + } finally { + unlink($file); + } } public function testProcessReturnsFalseWhenJsonCannotBeParsed(): void diff --git a/tests/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitorTest.php b/tests/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitorTest.php new file mode 100644 index 00000000..61e86b49 --- /dev/null +++ b/tests/Rule/Fixer/PhpParser/ClassConst/ChangeProtectedConstantToPrivateVisitorTest.php @@ -0,0 +1,92 @@ +makeClassConst('Grey', $flags); + $enum = new Enum_('Status', ['stmts' => [$classConst]]); + $changeProtectedConstantToPrivateVisitor = new ChangeProtectedConstantToPrivateVisitor('App\\Status', 'Grey'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedConstantToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PRIVATE | Modifiers::FINAL, $classConst->flags); + } + + public function testDoesNotChangeConstantInNonEnumClassLike(): void + { + $classConst = $this->makeClassConst('Grey', Modifiers::PROTECTED); + $class = new Class_('Status', ['stmts' => [$classConst]]); + $changeProtectedConstantToPrivateVisitor = new ChangeProtectedConstantToPrivateVisitor('App\\Status', 'Grey'); + + $class->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedConstantToPrivateVisitor))->traverse([$class]); + + $this->assertSame(Modifiers::PROTECTED, $classConst->flags); + } + + public function testDoesNotChangeConstantInDifferentEnum(): void + { + $classConst = $this->makeClassConst('Grey', Modifiers::PROTECTED); + $enum = new Enum_('Suit', ['stmts' => [$classConst]]); + $changeProtectedConstantToPrivateVisitor = new ChangeProtectedConstantToPrivateVisitor('App\\Status', 'Grey'); + + $enum->namespacedName = new Name('App\\Suit'); + + (new NodeTraverser($changeProtectedConstantToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PROTECTED, $classConst->flags); + } + + public function testDoesNotChangeDifferentConstant(): void + { + $classConst = $this->makeClassConst('Blue', Modifiers::PROTECTED); + $enum = new Enum_('Status', ['stmts' => [$classConst]]); + $changeProtectedConstantToPrivateVisitor = new ChangeProtectedConstantToPrivateVisitor('App\\Status', 'Grey'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedConstantToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PROTECTED, $classConst->flags); + } + + public function testDoesNotChangeNonProtectedConstant(): void + { + $classConst = $this->makeClassConst('Grey', Modifiers::PRIVATE); + $enum = new Enum_('Status', ['stmts' => [$classConst]]); + $changeProtectedConstantToPrivateVisitor = new ChangeProtectedConstantToPrivateVisitor('App\\Status', 'Grey'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedConstantToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PRIVATE, $classConst->flags); + } + + private function makeClassConst(string $constantName, int $flags): ClassConst + { + return new ClassConst([new Const_($constantName, new Int_(1))], $flags); + } +} diff --git a/tests/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitorTest.php b/tests/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitorTest.php new file mode 100644 index 00000000..546ec9db --- /dev/null +++ b/tests/Rule/Fixer/PhpParser/ClassMethod/ChangeProtectedMethodToPrivateVisitorTest.php @@ -0,0 +1,85 @@ + $flags]); + $enum = new Enum_('Status', ['stmts' => [$classMethod]]); + $changeProtectedMethodToPrivateVisitor = new ChangeProtectedMethodToPrivateVisitor('App\\Status', 'color'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedMethodToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PRIVATE | Modifiers::STATIC, $classMethod->flags); + } + + public function testDoesNotChangeMethodInNonEnumClassLike(): void + { + $classMethod = new ClassMethod('color', ['flags' => Modifiers::PROTECTED]); + $class = new Class_('Status', ['stmts' => [$classMethod]]); + $changeProtectedMethodToPrivateVisitor = new ChangeProtectedMethodToPrivateVisitor('App\\Status', 'color'); + + $class->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedMethodToPrivateVisitor))->traverse([$class]); + + $this->assertSame(Modifiers::PROTECTED, $classMethod->flags); + } + + public function testDoesNotChangeMethodInDifferentEnum(): void + { + $classMethod = new ClassMethod('color', ['flags' => Modifiers::PROTECTED]); + $enum = new Enum_('Suit', ['stmts' => [$classMethod]]); + $changeProtectedMethodToPrivateVisitor = new ChangeProtectedMethodToPrivateVisitor('App\\Status', 'color'); + + $enum->namespacedName = new Name('App\\Suit'); + + (new NodeTraverser($changeProtectedMethodToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PROTECTED, $classMethod->flags); + } + + public function testDoesNotChangeDifferentMethod(): void + { + $classMethod = new ClassMethod('label', ['flags' => Modifiers::PROTECTED]); + $enum = new Enum_('Status', ['stmts' => [$classMethod]]); + $changeProtectedMethodToPrivateVisitor = new ChangeProtectedMethodToPrivateVisitor('App\\Status', 'color'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedMethodToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PROTECTED, $classMethod->flags); + } + + public function testDoesNotChangeNonProtectedMethod(): void + { + $classMethod = new ClassMethod('color', ['flags' => Modifiers::PRIVATE]); + $enum = new Enum_('Status', ['stmts' => [$classMethod]]); + $changeProtectedMethodToPrivateVisitor = new ChangeProtectedMethodToPrivateVisitor('App\\Status', 'color'); + + $enum->namespacedName = new Name('App\\Status'); + + (new NodeTraverser($changeProtectedMethodToPrivateVisitor))->traverse([$enum]); + + $this->assertSame(Modifiers::PRIVATE, $classMethod->flags); + } +} diff --git a/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php b/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php index ab412054..7d4ade8f 100644 --- a/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php +++ b/tests/Rule/Fixer/PhpParser/ClassMethod/MethodVisibilityFixerPipelineTest.php @@ -45,6 +45,45 @@ static function save(): void } } + public function testProcessFixesMultipleMethodsInOneBatch(): void + { + $file = $this->temporaryPhpFile(<<<'PHP' +assertTrue($phpParserFixerProcessor->process($file, [ + new AddPublicMethodVisibilityVisitor('App\\Order', 'create'), + new AddPublicMethodVisibilityVisitor('App\\Order', 'save'), + ])); + $this->assertStringContainsString( + ' public function create(): void', + (string) file_get_contents($file) + ); + $this->assertStringContainsString( + ' public static function save(): void', + (string) file_get_contents($file) + ); + } finally { + unlink($file); + } + } + public function testProcessReturnsFalseForMissingFile(): void { $this->assertFalse($this->process(sys_get_temp_dir() . '/missing-structarmed.php', 'App\\Order', 'save')); diff --git a/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php b/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php new file mode 100644 index 00000000..35ebd2cb --- /dev/null +++ b/tests/Rule/Fixer/PhpParser/ConstFetch/LowercaseKeywordConstantVisitorTest.php @@ -0,0 +1,103 @@ +assertSame( + "\$a = FOO;\n\$b = true;", + $this->apply("assertSame( + 'return \null;', + $this->apply('assertSame( + 'return namespace\FALSE;', + $this->apply('assertSame( + '$value = true ? namespace\TRUE : false;', + $this->apply( + 'assertSame( + "\$a = TRUE;\n\$b = FALSE;", + $this->apply("assertSame( + '$a = FOO ?? \BAR ?? Foo\BAZ ?? Some::TRUE ?? true ?? \null;', + $this->apply($code, new LowercaseKeywordConstantVisitor(1, 'TRUE')) + ); + } + + public function testMatchesOnlyTheGivenSpelling(): void + { + $this->assertSame( + '$a = True && true;', + $this->apply('assertSame( + '$a = TRUE ? FALSE : NULL;', + $this->apply('assertSame( + '$a = true && true;', + $this->apply('createForNewestSupportedVersion()->parse($code); + $this->assertNotNull($statements); + + $statements = (new NodeTraverser($lowercaseKeywordConstantVisitor))->traverse($statements); + + return (new Standard())->prettyPrint($statements); + } +} diff --git a/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php b/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php new file mode 100644 index 00000000..5ee0bf8a --- /dev/null +++ b/tests/Rule/Fixer/PhpParser/FunctionLike/AddStaticAnonymousFunctionVisitorTest.php @@ -0,0 +1,111 @@ + 12]); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$closure]); + + $this->assertTrue($closure->static); + } + + public function testAddsStaticToArrowFunctionOnMatchingLine(): void + { + $arrowFunction = new ArrowFunction(['expr' => new Int_(1)], ['startLine' => 12]); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$arrowFunction]); + + $this->assertTrue($arrowFunction->static); + } + + public function testDoesNotChangeClosureOnDifferentLine(): void + { + $closure = new Closure([], ['startLine' => 13]); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$closure]); + + $this->assertFalse($closure->static); + } + + public function testDoesNotChangeAlreadyStaticClosure(): void + { + $closure = new Closure(['static' => true], ['startLine' => 12]); + $addStaticAnonymousFunctionVisitor = new AddStaticAnonymousFunctionVisitor(12); + + $this->assertNotInstanceOf(Node::class, $addStaticAnonymousFunctionVisitor->enterNode($closure)); + $this->assertTrue($closure->static); + } + + public function testDoesNotChangeAnonymousFunctionReadingThisOnTheSameLine(): void + { + // `[fn () => 1, fn () => $this->value]` on one line: only the first is a violation. + $plain = new ArrowFunction(['expr' => new Int_(1)], ['startLine' => 12]); + $usingThis = new ArrowFunction( + ['expr' => new PropertyFetch(new Variable('this'), new Identifier('value'))], + ['startLine' => 12] + ); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$plain, $usingThis]); + + $this->assertTrue($plain->static); + $this->assertFalse($usingThis->static); + } + + public function testDoesNotChangeClosureWhoseNestedClosureReadsThis(): void + { + $inner = new Closure(['stmts' => [new Return_(new Variable('this'))]], ['startLine' => 12]); + $outer = new Closure(['stmts' => [new Return_($inner)]], ['startLine' => 12]); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$outer]); + + $this->assertFalse($outer->static); + $this->assertFalse($inner->static); + } + + public function testChangesClosureWhoseNestedAnonymousClassReadsThis(): void + { + $anonymousClass = new Class_(null, [ + 'stmts' => [new ClassMethod('run', ['stmts' => [new Return_(new Variable('this'))]])], + ]); + $closure = new Closure(['stmts' => [new Return_(new New_($anonymousClass))]], ['startLine' => 12]); + + (new NodeTraverser(new AddStaticAnonymousFunctionVisitor(12)))->traverse([$closure]); + + $this->assertTrue($closure->static); + } + + public function testDoesNotChangeNonAnonymousFunctionNode(): void + { + $addStaticAnonymousFunctionVisitor = new AddStaticAnonymousFunctionVisitor(12); + + $this->assertNotInstanceOf( + Node::class, + $addStaticAnonymousFunctionVisitor->enterNode( + new ClassMethod('save', [], ['startLine' => 12]) + ) + ); + } +} diff --git a/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php new file mode 100644 index 00000000..29770016 --- /dev/null +++ b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleFixTest.php @@ -0,0 +1,149 @@ +makeTemporaryDirectory('structarmed-static-closure-line'); + mkdir($basePath . '/src'); + + $file = $basePath . '/src/Handler.php'; + + file_put_contents( + $file, + " 1, fn () => \$this->value, function () { return 2; }];\n" + . " }\n" + . "}\n" + ); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('source.static_closures', new MustBeStaticAnonymousFunctionRule(layer: 'Source')); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.static_closures'); + + $this->assertCount(2, $violations); + $this->assertSame(9, $violations[0]->line); + $this->assertSame(9, $violations[1]->line); + + $rule = $architecture->getRules()['source.static_closures']; + $this->assertInstanceOf(MustBeStaticAnonymousFunctionRule::class, $rule); + + $this->assertTrue($rule->fix($violations[0])); + + $this->assertSame( + " 1, fn () => \$this->value, static function () { return 2; }];\n" + . " }\n" + . "}\n", + file_get_contents($file) + ); + } + + public function testAnalyseThenFixAddsStaticOnlyToFlaggedAnonymousFunctions(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-static-closure'); + mkdir($basePath . '/src'); + + $file = $basePath . '/src/Handler.php'; + + file_put_contents( + $file, + " \$this->value,\n" + . " static fn () => 2,\n" + . " fn (int \$x) => \$x * 2,\n" + . " ];\n" + . " }\n" + . "}\n" + ); + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('source.static_closures', new MustBeStaticAnonymousFunctionRule(layer: 'Source')); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.static_closures'); + + $this->assertCount(2, $violations); + $this->assertSame([12, 15], [$violations[0]->line, $violations[1]->line]); + $this->assertTrue($violations[0]->fixable); + + $rule = $architecture->getRules()['source.static_closures']; + $this->assertInstanceOf(MustBeStaticAnonymousFunctionRule::class, $rule); + + foreach ($violations as $violation) { + $this->assertTrue($rule->fix($violation)); + } + + $this->assertSame( + " \$this->value,\n" + . " static fn () => 2,\n" + . " static fn (int \$x) => \$x * 2,\n" + . " ];\n" + . " }\n" + . "}\n", + file_get_contents($file) + ); + + // A second analysis of the fixed file is clean. + $this->assertCount( + 0, + (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule('source.static_closures') + ); + } +} diff --git a/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php new file mode 100644 index 00000000..ad0c1c3a --- /dev/null +++ b/tests/Rule/Function_/MustBeStaticAnonymousFunctionRuleTest.php @@ -0,0 +1,122 @@ +assertTrue($mustBeStaticAnonymousFunctionRule->appliesTo($this->makeNode())); + $this->assertFalse( + $mustBeStaticAnonymousFunctionRule->appliesTo($this->makeNode(layer: 'Infrastructure')) + ); + $this->assertFalse( + $mustBeStaticAnonymousFunctionRule->appliesTo($this->makeNode(layer: null)) + ); + } + + public function testPassesWhenAlreadyStatic(): void + { + $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); + + $this->assertNotInstanceOf( + RuleViolation::class, + $mustBeStaticAnonymousFunctionRule->evaluate($this->makeNode(isStatic: true)) + ); + } + + public function testPassesWhenClosureUsesThis(): void + { + $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); + + $this->assertNotInstanceOf( + RuleViolation::class, + $mustBeStaticAnonymousFunctionRule->evaluate($this->makeNode(usesThis: true)) + ); + } + + public function testViolatesForNonStaticClosure(): void + { + $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); + $violation = $mustBeStaticAnonymousFunctionRule->evaluate( + $this->makeNode() + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame('Closure in [App\\Domain\\Handler] must be declared static', $violation->message); + $this->assertSame('/src/Domain/Handler.php', $violation->file); + $this->assertSame(12, $violation->line); + $this->assertSame('App\\Domain\\Handler', $violation->className); + $this->assertSame('Domain', $violation->layer); + } + + public function testViolatesForNonStaticArrowFunctionAtFileScope(): void + { + $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); + $violation = $mustBeStaticAnonymousFunctionRule->evaluate( + $this->makeNode(isArrowFunction: true, enclosingClassName: null) + ); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame('Arrow function in [file scope] must be declared static', $violation->message); + $this->assertSame('file scope', $violation->className); + } + + public function testIsFixable(): void + { + $this->assertInstanceOf(FixableInterface::class, new MustBeStaticAnonymousFunctionRule(layer: 'Domain')); + } + + public function testCreatesStaticAnonymousFunctionFixerVisitor(): void + { + $mustBeStaticAnonymousFunctionRule = new MustBeStaticAnonymousFunctionRule(layer: 'Domain'); + $reflectionMethod = new ReflectionMethod( + $mustBeStaticAnonymousFunctionRule, + 'createFixerVisitor' + ); + $visitor = $reflectionMethod->invoke( + $mustBeStaticAnonymousFunctionRule, + new RuleViolation( + message: 'Closure in [App\\Domain\\Handler] must be declared static', + file: '/src/Domain/Handler.php', + line: 12, + className: 'App\\Domain\\Handler', + layer: 'Domain', + ) + ); + + $this->assertInstanceOf(AddStaticAnonymousFunctionVisitor::class, $visitor); + } +} diff --git a/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleFunctionalTest.php b/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleFunctionalTest.php new file mode 100644 index 00000000..fb598ad5 --- /dev/null +++ b/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleFunctionalTest.php @@ -0,0 +1,147 @@ +makeTempProject([ + 'src/Helper/functions.php' => <<<'PHP' + analyse($basePath)->forRule('helper.must_have_return_type'); + + $this->assertCount(2, $violations); + $this->assertStringContainsString('App\Helper\format_price()', $violations[0]->message); + $this->assertStringContainsString('App\Helper\format_date()', $violations[1]->message); + } + + public function testPassesWhenAllFunctionsDeclareReturnTypes(): void + { + $basePath = $this->makeTempProject([ + 'src/Helper/functions.php' => <<<'PHP' + analyse($basePath)->forRule('helper.must_have_return_type'); + + $this->assertCount(0, $violations); + } + + public function testMvcPresetFlagsUntypedHelperFunctions(): void + { + $basePath = $this->makeTempProject([ + 'src/Helper/functions.php' => <<<'PHP' + apply($architecture); + + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()) + ->forRule(MvcPreset::HELPER_MUST_HAVE_RETURN_TYPES); + + $this->assertCount(2, $violations); + $this->assertStringContainsString('App\Helper\format_price()', $violations[0]->message); + $this->assertStringContainsString('App\Helper\format_date()', $violations[1]->message); + } + + private function analyse(string $basePath): RuleViolationCollection + { + $architecture = Architecture::define() + ->layer('Helper', 'src/Helper/') + ->rule('helper.must_have_return_type', new MustHaveReturnTypeFunctionRule('Helper')); + + return (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()); + } + + /** @param array $files */ + private function makeTempProject(array $files): string + { + $basePath = $this->makeTemporaryDirectory('structarmed-must-have-return-type-function'); + + foreach ($files as $file => $contents) { + $path = $basePath . '/' . $file; + + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + + file_put_contents($path, $contents); + } + + return $basePath; + } +} diff --git a/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php b/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php new file mode 100644 index 00000000..e8727af4 --- /dev/null +++ b/tests/Rule/Function_/MustHaveReturnTypeFunctionRuleTest.php @@ -0,0 +1,87 @@ +makeNode(hasReturnType: true); + + $this->assertNotInstanceOf( + RuleViolation::class, + $mustHaveReturnTypeFunctionRule->evaluate($functionNode) + ); + } + + public function testViolatesWhenFunctionMissingReturnType(): void + { + $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); + $functionNode = $this->makeNode(hasReturnType: false); + + $violation = $mustHaveReturnTypeFunctionRule->evaluate($functionNode); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertStringContainsString('App\\Helper\\format_price()', $violation->message); + $this->assertSame('App\\Helper\\format_price', $violation->functionName); + $this->assertSame('Helper', $violation->layer); + } + + public function testAppliesToMatchingLayer(): void + { + $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); + + $this->assertTrue($mustHaveReturnTypeFunctionRule->appliesTo($this->makeNode())); + } + + public function testDoesNotApplyToWrongLayer(): void + { + $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); + $functionNode = $this->makeNode(layer: 'Controller'); + + $this->assertFalse($mustHaveReturnTypeFunctionRule->appliesTo($functionNode)); + } + + public function testSingleRuleInstanceReportsOneViolationPerFunction(): void + { + // One FunctionRuleInterface instance is evaluated once per function + // node, so multiple functions yield multiple independent violations. + $mustHaveReturnTypeFunctionRule = new MustHaveReturnTypeFunctionRule(layer: 'Helper'); + + $firstViolation = $mustHaveReturnTypeFunctionRule->evaluate( + $this->makeNode(functionName: 'App\\Helper\\format_price') + ); + $secondViolation = $mustHaveReturnTypeFunctionRule->evaluate( + $this->makeNode(functionName: 'App\\Helper\\format_date') + ); + + $this->assertInstanceOf(RuleViolation::class, $firstViolation); + $this->assertInstanceOf(RuleViolation::class, $secondViolation); + $this->assertStringContainsString('format_price', $firstViolation->message); + $this->assertStringContainsString('format_date', $secondViolation->message); + } +} diff --git a/tests/Rule/Layer/MayNotDependOnRuleTest.php b/tests/Rule/Layer/MayNotDependOnRuleTest.php index 8d8671b2..12d1380b 100644 --- a/tests/Rule/Layer/MayNotDependOnRuleTest.php +++ b/tests/Rule/Layer/MayNotDependOnRuleTest.php @@ -5,7 +5,7 @@ namespace Boundwize\StructArmed\Tests\Rule\Layer; use Boundwize\StructArmed\Analyser\ClassNode; -use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; +use Boundwize\StructArmed\Rule\AbstractLayerAwareRule; use Boundwize\StructArmed\Rule\Rules\Layer\MayNotDependOnRule; use Boundwize\StructArmed\Rule\RuleViolation; use PHPUnit\Framework\Attributes\CoversClass; @@ -164,10 +164,10 @@ public function testReportsMultipleViolationsWhenMultipleForbiddenDependencies() $this->assertStringContainsString('App\Infrastructure\B', $violations[1]->message); } - public function testImplementsLayerAwareRuleInterface(): void + public function testExtendsAbstractLayerAwareRule(): void { $this->assertInstanceOf( - LayerAwareRuleInterface::class, + AbstractLayerAwareRule::class, new MayNotDependOnRule(from: 'Domain', to: 'Infrastructure') ); } diff --git a/tests/Rule/RuleViolationTest.php b/tests/Rule/RuleViolationTest.php index e42002a3..8eca2001 100644 --- a/tests/Rule/RuleViolationTest.php +++ b/tests/Rule/RuleViolationTest.php @@ -69,6 +69,28 @@ className: 'App\\Domain\\File', ], $ruleViolation->toArray()); } + public function testViolationSerializesFunctionNameWhenPresent(): void + { + $ruleViolation = new RuleViolation( + message: 'Broken rule', + file: '/src/helpers.php', + line: 7, + className: 'App\\Support\\format', + ruleKey: 'first.rule', + functionName: 'App\\Support\\format', + ); + + $this->assertSame([ + 'rule' => 'first.rule', + 'message' => 'Broken rule', + 'file' => '/src/helpers.php', + 'line' => 7, + 'class' => 'App\\Support\\format', + 'layer' => null, + 'function' => 'App\\Support\\format', + ], $ruleViolation->toArray()); + } + public function testNonFixableViolationDoesNotSerializeFixableFlag(): void { $this->assertArrayNotHasKey('fixable', $this->violation('first.rule', 'Domain')->toArray()); @@ -113,6 +135,19 @@ className: 'App\\Domain\\File', $this->assertSame('status', $ruleViolation->toArray()['property']); } + public function testViolationSerializesNumericLiteralWhenPresent(): void + { + $ruleViolation = new RuleViolation( + message: 'Broken rule', + file: '/src/File.php', + line: 7, + className: '', + numericLiteral: '10000', + ); + + $this->assertSame('10000', $ruleViolation->toArray()['numericLiteral']); + } + public function testCollectionFiltersAndSerializesViolations(): void { $collection = new RuleViolationCollection(); diff --git a/tests/Support/TemporaryDirectoryCleanupTrait.php b/tests/Support/TemporaryDirectoryCleanupTrait.php index bc8f8463..0f5fbba9 100644 --- a/tests/Support/TemporaryDirectoryCleanupTrait.php +++ b/tests/Support/TemporaryDirectoryCleanupTrait.php @@ -45,7 +45,7 @@ protected function makeTemporaryDirectory(string $prefix): string $this->temporaryPaths[] = $basePath; // also clean up the default cache directory derived from this base path, - // created e.g. by ParallelClassNodeExtractor when no cache directory is configured + // created e.g. by ParallelAnalysisNodeExtractor when no cache directory is configured $this->temporaryPaths[] = CachePathFactory::getPath(null, $basePath); return $basePath; diff --git a/tests/Util/PhpParser/AnonymousClassParenthesesTest.php b/tests/Util/PhpParser/AnonymousClassParenthesesTest.php new file mode 100644 index 00000000..b4550fa7 --- /dev/null +++ b/tests/Util/PhpParser/AnonymousClassParenthesesTest.php @@ -0,0 +1,71 @@ +createForNewestSupportedVersion(); + $statements = $parser->parse($code); + $tokens = $parser->getTokens(); + + $class = (new NodeFinder())->findFirstInstanceOf($statements ?? [], Class_::class); + $this->assertInstanceOf(Class_::class, $class); + + $range = AnonymousClassParentheses::emptyTokenRange($tokens, $class); + + if ($expectedRangeText === null) { + $this->assertNull($range); + + return; + } + + $this->assertIsArray($range); + [$first, $last] = $range; + + $rangeText = ''; + for ($index = $first; $index <= $last; $index++) { + $rangeText .= $tokens[$index]->text; + } + + $this->assertSame($expectedRangeText, $rangeText); + } + + /** @return iterable */ + public static function anonymousClassProvider(): iterable + { + yield 'no parentheses' => [' [' [' [" [' [' [' [ + ' [' [' [" ['assertNull(AnonymousClassParentheses::emptyTokenRange([], new Class_(null))); + } +}