Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DRAFT] ComposerCollector: adding new parameters include and includeDev to separate the list of packages #80

Open
wants to merge 1 commit into
base: 2.0.x
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.idea
vendor/*
coverage/*
tools/
Expand Down
11 changes: 0 additions & 11 deletions baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,6 @@
<code><![CDATA[!$configuration['must_not']]]></code>
</RiskyTruthyFalsyComparison>
</file>
<file src="src/Core/Layer/Collector/ComposerFilesParser.php">
<InvalidReturnStatement>
<code>$lockedPackages</code>
</InvalidReturnStatement>
<InvalidReturnType>
<code><![CDATA[array<string, array{
* autoload?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* autoload-dev?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* }>]]></code>
</InvalidReturnType>
</file>
<file src="src/Core/Layer/Collector/DirectoryCollector.php">
<ArgumentTypeCoercion>
<code>$validatedPattern</code>
Expand Down
2 changes: 1 addition & 1 deletion docs/collectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ The `composer` collector allows you to define dependencies on composer `require`
- That your `require-dev` dependencies are only used in you non-production code (like DB migrations or SA tools)
- That your code does not use any transitive dependencies (dependencies on packages installed only because your `composer.json` required packages depend on them themselves)
- That some packages are only used in particular layers

### TODO Change docs after review!
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After review

```yaml
deptrac:
layers:
Expand Down
10 changes: 8 additions & 2 deletions src/Contract/Config/Collector/ComposerConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,16 @@ final class ComposerConfig extends CollectorConfig
private function __construct(
private readonly string $composerPath,
private readonly string $composerLockPath,
private readonly bool $include,
private readonly bool $includeDev,
) {}

/**
* @param list<string> $packages
*/
public static function create(string $composerPath = 'composer.json', string $composerLockPath = 'composer.lock', array $packages = []): self
public static function create(string $composerPath = 'composer.json', string $composerLockPath = 'composer.lock', array $packages = [], bool $include = true, bool $includeDev = true): self
{
$result = new self($composerPath, $composerLockPath);
$result = new self($composerPath, $composerLockPath, $include, $includeDev);
foreach ($packages as $package) {
$result->addPackage($package);
}
Expand All @@ -41,6 +43,8 @@ public function addPackage(string $package): self
* composerPath: string,
* composerLockPath: string,
* packages: list<string>,
* include: bool,
* includeDev: bool,
* private: bool,
* type: string}
*/
Expand All @@ -50,6 +54,8 @@ public function toArray(): array
'composerPath' => $this->composerPath,
'composerLockPath' => $this->composerLockPath,
'packages' => $this->packages,
'include' => $this->include,
'includeDev' => $this->includeDev,
'private' => $this->private,
'type' => $this->collectorType->value,
];
Expand Down
29 changes: 21 additions & 8 deletions src/Core/Layer/Collector/ComposerCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,35 @@ public function satisfy(array $config, TokenReferenceInterface $reference): bool
throw InvalidCollectorDefinitionException::invalidCollectorConfiguration('ComposerCollector needs the path to the composer.lock file as string.');
}

if (!isset($config['packages']) || !is_array($config['packages'])) {
// packages is optional
if (isset($config['packages']) && !is_array($config['packages'])) {
throw InvalidCollectorDefinitionException::invalidCollectorConfiguration('ComposerCollector needs the list of packages as string.');
}

if (!isset($config['include']) || !\is_bool($config['include'])) {
throw InvalidCollectorDefinitionException::invalidCollectorConfiguration('ComposerCollector needs the include for packages as bool.');
}

if (!isset($config['includeDev']) || !\is_bool($config['includeDev'])) {
throw InvalidCollectorDefinitionException::invalidCollectorConfiguration('ComposerCollector needs the includeDev for dev-packages as bool.');
}

if (!$config['include'] && !$config['includeDev']) {
throw InvalidCollectorDefinitionException::invalidCollectorConfiguration('ComposerCollector needs at least one true value from the include and includeDev');
}

try {
$this->parser[$config['composerLockPath']] ??= new ComposerFilesParser($config['composerLockPath']);
$parser = $this->parser[$config['composerLockPath']];
$parser = $this->parser[$config['composerLockPath']] ??= new ComposerFilesParser($config['composerLockPath']);
} catch (RuntimeException $exception) {
throw new CouldNotParseFileException('Could not parse composer files.', 0, $exception);
}

try {
$namespaces = $parser->autoloadableNamespacesForRequirements($config['packages'], true);
} catch (RuntimeException $e) {
throw InvalidCollectorDefinitionException::invalidCollectorConfiguration(sprintf('ComposerCollector has a non-existent package defined. %s', $e->getMessage()));
}
/**
* @var array<string> $packages
*/
$packages = $config['packages'] !== [] ? $config['packages'] : [];

$namespaces = $parser->autoloadableNamespacesForRequirements($packages, $config['include'], $config['includeDev']);

$token = $reference->getToken()->toString();

Expand Down
147 changes: 69 additions & 78 deletions src/Core/Layer/Collector/ComposerFilesParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,62 +7,21 @@
use JsonException;
use RuntimeException;

/**
* @psalm-type Autoload = array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>}
* @psalm-type Package = array{name: string, autoload?: Autoload, autoload-dev?: Autoload}
* @psalm-type GroupedPackages = array{packages: array<string, Package>, packages-dev: array<string, Package>}
*/
class ComposerFilesParser
{
/**
* @var array{
* packages?: array<string, array{
* name: string,
* autoload?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* autoload-dev?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* }>,
* packages-dev?: array<string, array{
* name: string,
* autoload?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* autoload-dev?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* }>,
* }
*/
private array $lockFile;

/**
* @var array<string, array{
* autoload?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* autoload-dev?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* }>
* @var GroupedPackages
*/
private array $lockedPackages;

/**
* @throws RuntimeException
*/
public function __construct(string $lockFile)
{
$contents = file_get_contents($lockFile);
if (false === $contents) {
throw new RuntimeException('Could not load composer.lock file');
}
try {
/**
* @var array{
* packages?: array<string, array{
* name: string,
* autoload?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* autoload-dev?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* }>,
* packages-dev?: array<string, array{
* name: string,
* autoload?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* autoload-dev?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* }>,
* } $jsonDecode
*/
$jsonDecode = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
$this->lockFile = $jsonDecode;
} catch (JsonException $exception) {
throw new RuntimeException('Could not parse composer.lock file', 0, $exception);
}
$this->lockedPackages = $this->getPackagesFromLockFile();
$this->lockedPackages = $this->getPackagesFromLockFile($lockFile);
}

/**
Expand All @@ -71,69 +30,101 @@ public function __construct(string $lockFile)
* @param string[] $requirements
*
* @return string[]
*
* @throws RuntimeException
*/
public function autoloadableNamespacesForRequirements(array $requirements, bool $includeDev): array
public function autoloadableNamespacesForRequirements(array $requirements, bool $include, bool $includeDev): array
{
$namespaces = [[]];
/**
* @var array<string, array<string>> $result
*/
$result = ['' => ['']];

if ($include) {
$result += $this->iterate($this->lockedPackages['packages'], false);
}

foreach ($requirements as $package) {
if (!array_key_exists($package, $this->lockedPackages)) {
throw new RuntimeException(sprintf('Could not find a "%s" package', $package));
if ($includeDev) {
$result += $this->iterate($this->lockedPackages['packages-dev'], true);
}

if ($requirements !== []) {
$filtered = [];
foreach ($requirements as $packageName) {
if (isset($result[$packageName])) {
$filtered[] = $result[$packageName];
}
}

$namespaces[] = $this->extractNamespaces($this->lockedPackages[$package], $includeDev);
$result = $filtered;
}

return array_merge(...$namespaces);
return array_merge(...array_values($result));
}

/**
* @return array<string, array{
* autoload?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* autoload-dev?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* }>
* @param array<Package> $packages
* @return array<string, array<string>>
*/
private function getPackagesFromLockFile(): array
private function iterate(array $packages, bool $includeDev): array
{
$lockedPackages = [];
$result = [];

foreach ($this->lockFile['packages'] ?? [] as $package) {
$lockedPackages[$package['name']] = $package;
foreach ($packages as $package) {
foreach ($this->extractNamespaces($package, $includeDev) as $packageName => $namespace) {
$result[$packageName][] = $namespace;
}
}

foreach ($this->lockFile['packages-dev'] ?? [] as $package) {
$lockedPackages[$package['name']] = $package;
return $result;
}

/**
* @throws RuntimeException
* @return GroupedPackages
*/
private function getPackagesFromLockFile(string $lockFile): array
{
$contents = file_get_contents($lockFile);
if (false === $contents) {
throw new RuntimeException('Could not load composer.lock file');
}
try {
/**
* @var array{packages: array<string, Package>, packages-dev: array<string, Package>} $lockPackages
*/
$lockPackages = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('Could not parse composer.lock file', 0, $exception);
}

return $lockedPackages;
// Group packages by dev and non-dev principle
return [
'packages' => array_column($lockPackages['packages'], null, 'name'),
'packages-dev' => array_column($lockPackages['packages-dev'], null, 'name'),
];
}

/**
* @param array{
* autoload?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* autoload-dev?: array{'psr-0'?: array<string, string>, 'psr-4'?: array<string, string>},
* } $package
*
* @return string[]
* @param Package $package
* @return array<string, string>
*/
private function extractNamespaces(array $package, bool $includeDev): array
{
$namespaces = [];

foreach (array_keys($package['autoload']['psr-0'] ?? []) as $namespace) {
$namespaces[] = $namespace;
$namespaces[$package['name']] = $namespace;
}

foreach (array_keys($package['autoload']['psr-4'] ?? []) as $namespace) {
$namespaces[] = $namespace;
$namespaces[$package['name']] = $namespace;
}

if ($includeDev) {
foreach (array_keys($package['autoload-dev']['psr-0'] ?? []) as $namespace) {
$namespaces[] = $namespace;
$namespaces[$package['name']] = $namespace;
}
foreach (array_keys($package['autoload-dev']['psr-4'] ?? []) as $namespace) {
$namespaces[] = $namespace;
$namespaces[$package['name']] = $namespace;
}
}

Expand Down
38 changes: 38 additions & 0 deletions tests/Core/Layer/Collector/ComposerCollectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public static function dataProviderSatisfy(): iterable
'composerPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.json',
'composerLockPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.lock',
'packages' => ['phpstan/phpdoc-parser'],
'include' => true,
'includeDev' => true,
],
'PHPStan\\PhpDocParser\\Ast\\Attribute',
true,
Expand All @@ -36,10 +38,45 @@ public static function dataProviderSatisfy(): iterable
'composerPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.json',
'composerLockPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.lock',
'packages' => ['phpstan/phpdoc-parser'],
'include' => true,
'includeDev' => true,
],
'Completely\\Wrong\\Namespace\\Attribute',
false,
];
yield [
[
'composerPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.json',
'composerLockPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.lock',
'packages' => ['friendsofphp/php-cs-fixer'],
'include' => true,
'includeDev' => true,
],
'PhpCsFixer\\FileReader',
true,
];
yield [
[
'composerPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.json',
'composerLockPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.lock',
'packages' => [],
'include' => false,
'includeDev' => true,
],
'PhpCsFixer\\Config',
true,
];
yield [
[
'composerPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.json',
'composerLockPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.lock',
'packages' => [],
'include' => true,
'includeDev' => false,
],
'PhpCsFixer\\Config',
false,
];
}

/**
Expand All @@ -64,6 +101,7 @@ public function testComposerPackageDoesNotExist(): void
'composerPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.json',
'composerLockPath' => __DIR__.DIRECTORY_SEPARATOR.'data/composer.lock',
'packages' => ['fake_package'],
'includeDev' => true
],
new ClassLikeReference(ClassLikeToken::fromFQCN(''), ClassLikeType::TYPE_CLASS),
);
Expand Down