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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
tests:
name: Tests (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
- run: composer update --no-interaction --no-progress --prefer-dist
- run: vendor/bin/phpunit

static-analysis:
name: PHPStan and architecture
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- run: composer update --no-interaction --no-progress --prefer-dist
- run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G
- name: php-deps checks itself
run: php analyze.php --check --format=github --quiet
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

**Static analysis tool for visualizing PHP class dependencies and call graphs.**

[![CI](https://github.com/furybee/php-deps/actions/workflows/ci.yml/badge.svg)](https://github.com/furybee/php-deps/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![PHP](https://img.shields.io/badge/PHP-8.1+-8892BF.svg)](https://www.php.net)
[![Laravel](https://img.shields.io/badge/Laravel-Compatible-FF2D20.svg)](https://laravel.com)
Expand Down Expand Up @@ -303,9 +304,13 @@ php-deps/

```bash
composer install
composer test
composer test # PHPUnit
composer analyse # PHPStan, level max on src and tests
php analyze.php --check # php-deps checks its own layers and cycles (php-deps.php)
```

CI runs the tests on PHP 8.1 to 8.4, PHPStan and the self-check.

## Performance

Tested on a real Laravel project:
Expand Down
14 changes: 12 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
"nikic/php-parser": "^5.0"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
"phpunit/phpunit": "^10.5",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/extension-installer": "^1.4"
},
"autoload": {
"psr-4": {
Expand All @@ -24,6 +27,13 @@
"analyze.php"
],
"scripts": {
"test": "phpunit"
"test": "phpunit",
"analyse": "phpstan analyse --memory-limit=1G"
},
"config": {
"allow-plugins": {
"phpstan/extension-installer": true
},
"sort-packages": true
}
}
19 changes: 19 additions & 0 deletions php-deps.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

// php-deps checks itself: php analyze.php --check

return [
'paths' => ['src'],
'layers' => [
'Console' => ['DepsGraph\Console\**'],
'Architecture' => ['DepsGraph\Architecture\**'],
'Metrics' => ['DepsGraph\Metrics\**'],
'Analysis' => ['DepsGraph\*'],
],
'rules' => [
'Analysis' => [],
'Metrics' => ['Analysis'],
'Architecture' => ['Metrics', 'Analysis'],
],
'cycles' => ['level' => 'class'],
];
8 changes: 8 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
parameters:
level: max
paths:
- src
- tests
- analyze.php
excludePaths:
- tests/Fixtures
11 changes: 8 additions & 3 deletions src/Analyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
use PhpParser\Parser;
use PhpParser\ParserFactory;

/**
* @phpstan-import-type ClassData from ClassVisitor
* @phpstan-import-type Graph from GraphBuilder
*/
final class Analyzer
{
private readonly string $basePath;
Expand Down Expand Up @@ -39,7 +43,7 @@ public function __construct(
}

/**
* @return array{root: string, nodes: array<mixed>, edges: array<mixed>, stats: array<string, mixed>}
* @return Graph
*/
public function analyze(): array
{
Expand All @@ -62,7 +66,7 @@ public function analyze(): array

/**
* @param string[] $files
* @return array<string, array<string, mixed>>
* @return array<string, ClassData>
*/
private function extractClasses(array $files): array
{
Expand Down Expand Up @@ -115,7 +119,7 @@ private function traverseFiles(array $files, NodeVisitorAbstract $visitor, \Clos
}

/**
* @return string[]
* @return list<string>
*/
private function findPhpFiles(): array
{
Expand All @@ -137,6 +141,7 @@ private function findPhpFiles(): array
),
);

/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if ($file->getExtension() === 'php') {
$files[] = $file->getPathname();
Expand Down
25 changes: 4 additions & 21 deletions src/Architecture/ArchitectureConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ public static function fromFile(string $file): self
}

return new self(
layers: self::listMap($config['layers'] ?? [], 'layers'),
rules: self::listMap($config['rules'] ?? [], 'rules'),
paths: array_values((array) ($config['paths'] ?? [])),
exclude: isset($config['exclude']) ? array_values((array) $config['exclude']) : null,
layers: ConfigValue::listMap($config['layers'] ?? [], 'layers'),
rules: ConfigValue::listMap($config['rules'] ?? [], 'rules'),
paths: ConfigValue::stringList($config['paths'] ?? [], 'paths'),
exclude: isset($config['exclude']) ? ConfigValue::stringList($config['exclude'], 'exclude') : null,
baseline: is_string($baseline) ? $baseline : null,
cycles: self::cycleRule($config['cycles'] ?? null),
);
Expand All @@ -69,21 +69,4 @@ private static function cycleRule(mixed $value): ?CycleRule
default => throw new InvalidConfigException('"cycles" must be a boolean or an array'),
};
}

/**
* @return array<string, string[]>
*/
private static function listMap(mixed $value, string $key): array
{
if (!is_array($value)) {
throw new InvalidConfigException("\"{$key}\" must be an array of lists");
}

$map = [];
foreach ($value as $name => $items) {
$map[(string) $name] = array_values(array_map('strval', (array) $items));
}

return $map;
}
}
2 changes: 1 addition & 1 deletion src/Architecture/Baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public static function load(string $file): self
throw new InvalidConfigException("Invalid baseline file: {$file}");
}

return new self(array_fill_keys(array_map('strval', $keys), true));
return new self(array_fill_keys(ConfigValue::stringList($keys, 'baseline'), true));
}

/**
Expand Down
62 changes: 62 additions & 0 deletions src/Architecture/ConfigValue.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace DepsGraph\Architecture;

/**
* Validates values read from php-deps.php, whose content is user input.
*/
final class ConfigValue
{
/**
* @return list<string>
*/
public static function stringList(mixed $value, string $key): array
{
$values = is_array($value) ? array_values($value) : [$value];
foreach ($values as $item) {
if (!is_string($item)) {
throw new InvalidConfigException("\"{$key}\" must be a string or a list of strings");
}
}

/** @var list<string> $values */
return $values;
}

/**
* @return array<string, list<string>>
*/
public static function listMap(mixed $value, string $key): array
{
if (!is_array($value)) {
throw new InvalidConfigException("\"{$key}\" must be an array of lists");
}

$map = [];
foreach ($value as $name => $items) {
$map[(string) $name] = self::stringList($items, "{$key}.{$name}");
}

return $map;
}

public static function string(mixed $value, string $key): string
{
if (!is_string($value)) {
throw new InvalidConfigException("\"{$key}\" must be a string");
}

return $value;
}

public static function int(mixed $value, string $key): int
{
if (!is_int($value)) {
throw new InvalidConfigException("\"{$key}\" must be an integer");
}

return $value;
}
}
10 changes: 5 additions & 5 deletions src/Architecture/CycleRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ public function __construct(
}

/**
* @param array<string, mixed> $config
* @param array<mixed> $config
*/
public static function fromArray(array $config): self
{
return new self(
level: (string) ($config['level'] ?? self::LEVEL_NAMESPACE),
depth: (int) ($config['depth'] ?? 0),
scope: array_values(array_map('strval', (array) ($config['scope'] ?? ['**']))),
ignore: array_values(array_map('strval', (array) ($config['ignore'] ?? []))),
level: ConfigValue::string($config['level'] ?? self::LEVEL_NAMESPACE, 'cycles.level'),
depth: ConfigValue::int($config['depth'] ?? 0, 'cycles.depth'),
scope: ConfigValue::stringList($config['scope'] ?? ['**'], 'cycles.scope'),
ignore: ConfigValue::stringList($config['ignore'] ?? [], 'cycles.ignore'),
);
}

Expand Down
7 changes: 5 additions & 2 deletions src/Architecture/CycleRuleChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
use DepsGraph\Metrics\CycleDetector;
use DepsGraph\Metrics\DependencyMap;

/**
* @phpstan-import-type MinimalGraph from DependencyMap
*/
final class CycleRuleChecker
{
public function __construct(
Expand All @@ -16,7 +19,7 @@ public function __construct(
}

/**
* @param array{nodes: array<array<string, mixed>>, edges: array<array{source: string, target: string, type: string, line?: int}>} $graph
* @param MinimalGraph $graph
* @return CycleViolation[]
*/
public function check(array $graph): array
Expand All @@ -32,7 +35,7 @@ public function check(array $graph): array
$files = [];
foreach ($graph['nodes'] as $node) {
if ($node['type'] === 'class') {
$files[$node['id']] = (string) ($node['file'] ?? '');
$files[$node['id']] = $node['file'] ?? '';
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/Architecture/DependencyRuleChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@

namespace DepsGraph\Architecture;

use DepsGraph\Metrics\DependencyMap;

/**
* Checks graph edges against the allowed layer dependencies.
* Classes outside every layer are not checked, nor are layers without rules.
*
* @phpstan-import-type MinimalGraph from DependencyMap
*/
final class DependencyRuleChecker
{
Expand All @@ -15,7 +19,7 @@ public function __construct(private readonly ArchitectureConfig $config)
}

/**
* @param array{nodes: array<array<string, mixed>>, edges: array<array{source: string, target: string, type: string, line?: int}>} $graph
* @param MinimalGraph $graph
* @return Violation[] one per class pair, at its first occurrence
*/
public function check(array $graph): array
Expand All @@ -24,7 +28,7 @@ public function check(array $graph): array
$files = [];
foreach ($graph['nodes'] as $node) {
if ($node['type'] === 'class') {
$files[$node['id']] = (string) ($node['file'] ?? '');
$files[$node['id']] = $node['file'] ?? '';
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/CallVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@
* Expects PhpParser's NameResolver to run before it in the same traverser.
*
* Edges are raw: targets may be external or inherited members, GraphBuilder sorts them out.
*
* @phpstan-import-type Edge from GraphBuilder
*/
final class CallVisitor extends NodeVisitorAbstract
{
private const CONTAINER_FUNCTIONS = ['app', 'resolve'];
private const CONTAINER_METHODS = ['make', 'makewith'];
private const CONTAINER_CLASSES = ['Illuminate\Support\Facades\App', 'App', 'Illuminate\Container\Container'];

/** @var array<array{source: string, target: string, type: string, line: int}> */
/** @var list<Edge> */
private array $edges = [];

/** @var ClassScope[] */
Expand All @@ -36,7 +38,7 @@ public function __construct(
}

/**
* @return array<array{source: string, target: string, type: string, line: int}>
* @return list<Edge>
*/
public function getEdges(): array
{
Expand Down Expand Up @@ -160,6 +162,9 @@ private function collectLocalTypes(Node $node): void
private function collectEdges(Node $node): void
{
$frame = end($this->frames);
if ($frame === false) {
return;
}
$from = $frame['scope']->class . '::' . $frame['method'];
$line = $node->getStartLine();

Expand Down
Loading
Loading