diff --git a/.env b/.env
index 8dd6973..bd95dd7 100644
--- a/.env
+++ b/.env
@@ -1,9 +1,13 @@
WARDEN_ENV_NAME=depends
WARDEN_ENV_TYPE=laravel
WARDEN_WEB_ROOT=/
-WARDEN_PHP=1
-COMPOSER_VERSION=2
-PHP_VERSION=7.4
+
TRAEFIK_DOMAIN=depends.test
TRAEFIK_SUBDOMAIN=app
+
+WARDEN_PHP=1
+PHP_VERSION=8.0
+PHP_XDEBUG_3=1
+
+COMPOSER_VERSION=2
WARDEN_COMPOSER_DIR=./.composer
diff --git a/.github/workflows/commit.yml b/.github/workflows/commit.yml
index 644a50d..05f8ceb 100644
--- a/.github/workflows/commit.yml
+++ b/.github/workflows/commit.yml
@@ -1,21 +1,81 @@
-name: Static Analysis Test
+name: Continuous Integration
on: [push]
jobs:
+ phpunit:
+ name: Unit Tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.0
+ coverage: xdebug
+ tools: composer:v2
+
+ - name: Checkout Repository
+ uses: actions/checkout@v2
+
+ - name: Install Dependencies
+ run: composer install
+
+ - name: Test
+ run: vendor/bin/phpunit tests --testdox --coverage-clover coverage.xml
+
+ - name: Upload to CodeCov
+ uses: codecov/codecov-action@v1
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ files: coverage.xml
+ fail_ci_if_error: true
+
phpstan:
name: Static Analysis Check
runs-on: ubuntu-latest
container:
- image: davidalger/php:7.2
+ image: davidalger/php:8.0
steps:
- name: Checkout Repository
- uses: actions/checkout@v1
+ uses: actions/checkout@v2
+
+ - name: Install Dependencies
+ run: composer2 install
- - name: Install Prestissimo
- run: composer global require hirak/prestissimo
+ - name: Run PHPStan
+ run: vendor/bin/phpstan
+
+ phpcs:
+ name: Code Style (PSR-12)
+ runs-on: ubuntu-latest
+ container:
+ image: davidalger/php:8.0
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v2
- name: Install Dependencies
+ run: composer2 install
+
+ - name: PHP Code Sniffer
+ run: vendor/bin/phpcs
+
+ infection:
+ name: Infection Check
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.0
+ coverage: xdebug
+ tools: composer:v2
+
+ - name: Checkout Repository
+ uses: actions/checkout@v2
+
+ - name: Install dependencies
run: composer install
- - name: Run PHPStan
- run: vendor/bin/phpstan analyse src --level max
+ - name: Check for Mutants
+ run: vendor/bin/infection --threads=$(nproc) --no-progress --logger-github
diff --git a/README.md b/README.md
index 0952064..477f7a0 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,38 @@
-# The @dependency Annotation
-![Static Analysis Test](https://github.com/navarr/dependency-annotation/workflows/Static%20Analysis%20Test/badge.svg)
+# The #[Dependency] Attribute
+[![Latest Stable Version](http://poser.pugx.org/navarr/dependency-annotation/v)](https://packagist.org/packages/navarr/dependency-annotation)
+[![Total Downloads](http://poser.pugx.org/navarr/dependency-annotation/downloads)](https://packagist.org/packages/navarr/dependency-annotation)
+[![Latest Unstable Version](http://poser.pugx.org/navarr/dependency-annotation/v/unstable)](https://packagist.org/packages/navarr/dependency-annotation)
+[![License](http://poser.pugx.org/navarr/dependency-annotation/license)](https://packagist.org/packages/navarr/dependency-annotation)
+![Tests](https://github.com/navarr/dependency-annotation/actions/workflows/commit.yml/badge.svg)
+![Code Coverage](https://codecov.io/gh/navarr/dependency-annotation/branch/main/graph/badge.svg?token=BHTKOZZDR3)
-This project supplies a Composer plugin that adds a command (`why-block`) that interprets the PHP `@dependency`
- annotation.
+This project supplies a Composer plugin that adds a command (`why-block`) that interprets a PHP `#[Dependency]`
+ attribute.
-## How to use the `@dependency` annotation
+## How to use the `#[Dependency]` annotation
-Simply include a `@dependency` annotation in any slash-based comment block, in the following format:
+Simply include a `#[Dependency]` attribute on any attributable target in the following format:
- @dependency composer-package:version-constraint [Explanation]
+ #[Navarr\Depends\Annotation\Dependency('package', 'versionConstraint', 'reason')]
-All fields except the explanation are mandatory. Adding an explanation is _highly recommended_, however.
+This FQN may be imported, in which case you can simply use `#[Dependency(...)]`
-The version-constraint field cannot contain spaces (even if surrounded by quotes).
+All fields except the explanation are mandatory. Adding an explanation is _highly recommended_, however.
## How to process reasons not to upgrade a composer dependency
-If you are using the `@dependency` annotation thoroughly, and you are having issues updating a composer dependency, you
+If you are using the `#[Dependency]` annotation thoroughly, and you are having issues updating a composer dependency, you
can use the command `composer why-block composer-package version`
-This will output a list of files containing a `@dependency` annotation on composer-package with a version-constraint
- that cannot be fulfilled by your specified version.
+This will output a list of files containing a `#[Dependency]` annotation on composer-package with a version-constraint
+ that cannot be fulfilled by the specified version.
## How to install
`composer global require navarr/dependency-annotation`
+
+## Compatibility with v1
+
+For speed, version 2 automatically excludes the legacy `@dependency` annotation in favor of the PHP8 `#[Dependency]`
+attribute. While transitioning, you may specify the `-l` or `--include-legacy-annotations` flag to the `why-block`
+command to force it to process v1 annotations as well.
diff --git a/composer.json b/composer.json
index 6d06ea8..b0905db 100644
--- a/composer.json
+++ b/composer.json
@@ -4,23 +4,47 @@
"type": "composer-plugin",
"license": "MIT",
"require": {
- "php": "^7.2",
- "composer-plugin-api": "^1|^2",
- "composer/composer": "^1|^2",
+ "php": "^7.1|^8",
+ "composer-plugin-api": "^2",
+ "composer/composer": "^2",
"composer/semver": "^1|^2|^3",
- "symfony/console": "^5"
+ "symfony/console": "^5",
+ "nikic/php-parser": "^4",
+ "navarr/attribute-dependency": "^1.0.1",
+ "php-di/php-di": "^6"
+ },
+ "suggest": {
+ "ext-fileinfo": "Use MIME types for PHP file detection",
+ "ext-json": "Required to use JSON Output",
+ "ext-simplexml": "Required to use XML Output"
},
"require-dev": {
+ "php": "^8",
"roave/security-advisories": "dev-master",
- "phpstan/phpstan": "^0.12.32"
+ "phpstan/phpstan": "^0.12.32",
+ "phpunit/phpunit": "^9.5",
+ "infection/infection": "^0.23.0",
+ "squizlabs/php_codesniffer": "^3.6",
+ "jetbrains/phpstorm-attributes": "^1.0"
},
"autoload": {
"psr-4": {
"Navarr\\Depends\\": "src/"
+ },
+ "files": [
+ "src/polyfills/preg_last_error_msg.func.php"
+ ]
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Navarr\\Depends\\Test\\": "tests/"
}
},
"extra": {
- "class": "Navarr\\Depends\\Plugin"
+ "class": "Navarr\\Depends\\Controller\\Composer\\ComposerPlugin",
+ "branch-alias": {
+ "dev-php8-annotation": "2.x-dev"
+ }
},
"archive": {
"exclude": [
diff --git a/infection.json.dist b/infection.json.dist
new file mode 100644
index 0000000..1ed51b8
--- /dev/null
+++ b/infection.json.dist
@@ -0,0 +1,25 @@
+{
+ "source": {
+ "directories": [
+ "src"
+ ]
+ },
+ "phpUnit": {
+ "configDir": "."
+ },
+ "mutators": {
+ "@default": true,
+ "ConcatOperandRemoval": {
+ "ignore": [
+ "Navarr\\Depends\\Parser\\AstParser::parse::82",
+ "Navarr\\Depends\\Parser\\AstParser::parse::83"
+ ]
+ },
+ "Concat": {
+ "ignore": [
+ "Navarr\\Depends\\Parser\\AstParser::parse::82",
+ "Navarr\\Depends\\Parser\\AstParser::parse::83"
+ ]
+ }
+ }
+}
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..1447257
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,5 @@
+
+
+
+ src
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..1ff472c
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,4 @@
+parameters:
+ level: 8
+ paths:
+ - src
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..5ffbe0a
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,15 @@
+
+
+
+ tests
+
+
+
+ src
+
+
+ src/polyfills
+ src/Proxy/MimeDeterminer.php
+
+
+
diff --git a/src/Command/WhyBlockCommand.php b/src/Command/WhyBlockCommand.php
index 3e7f4f9..a10e151 100644
--- a/src/Command/WhyBlockCommand.php
+++ b/src/Command/WhyBlockCommand.php
@@ -1,242 +1,50 @@
setName('why-block')
- ->addArgument('package', InputArgument::REQUIRED, 'Package to inspect')
- ->addArgument('version', InputArgument::REQUIRED, 'Version you want to update it to')
- ->addOption(
- self::ROOT_DEPS,
- ['r'],
- InputOption::VALUE_NONE,
- 'Whether or not to search root dependencies for the @dependency annotation',
- null
- )
- ->addOption(
- self::ALL_DEPS,
- ['a'],
- InputOption::VALUE_NONE,
- 'Whether or not to search all dependencies for the @dependency annotation',
- null
- );
- }
-
- /**
- * @dependency symfony/console:^5 InputInterface's getOption method
- * @dependency symfony/console:^5 OutputInterface's writeln method
- *
- * @param InputInterface $input
- * @param OutputInterface $output
- * @return int Exit code
- */
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- /** @var Composer $composer required indicates it can never be null. */
- $composer = $this->getComposer(true);
-
- // Always check the base files
- $results = static::getAllFilesForAutoload('.', $composer->getPackage()->getAutoload());
-
- $packages = [];
- // If we're checking dependencies, grab all packages
- if ($input->getOption(self::ROOT_DEPS) || $input->getOption(self::ALL_DEPS)) {
- $packages = $composer->getRepositoryManager()->getLocalRepository()->getPackages();
- }
-
- // If we're only checking root dependencies, determine them and filter down `$packages`
- if ($input->getOption(self::ROOT_DEPS)) {
- $requires = array_map(
- static function (Link $link) {
- return $link->getTarget();
- },
- $composer->getPackage()->getRequires()
- );
- $packages = array_filter(
- $packages,
- static function (PackageInterface $package) use ($requires) {
- return in_array($package->getName(), $requires, true);
- }
- );
- }
-
- // Find all files for the packages
- foreach ($packages as $package) {
- $path = 'vendor' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $package->getName());
- $results = static::getAllFilesForAutoload($path, $package->getAutoload(), $results);
- }
-
- $found = false;
- foreach ($results as $file) {
- $contents = file_get_contents($file);
- if ($contents === false) {
- continue;
- }
- $matches = [];
-
- // Double slash comments
- preg_match_all(
- '#//\s+@(dependency|composerDependency)\s+([^:\s]+):(\S+)\s(.*)?(?=$)#im',
- $contents,
- $matches,
- PREG_OFFSET_CAPTURE
- );
- $found = $this->processMatches($matches, $input, $contents, $output, $file);
-
- // Slash asterisk comments. We're cheating here and only using an asterisk as indicator. False
- // positives possible.
- preg_match_all(
- '#\*\s+@(dependency|composerDependency)\s+([^:]+):(\S+) ?(.*)$#im',
- $contents,
- $matches,
- PREG_OFFSET_CAPTURE
- );
- $found = $this->processMatches($matches, $input, $contents, $output, $file) || $found;
- }
-
- if (!$found) {
- /** @var string $package */
- $package = $input->getArgument('package');
- $output->writeln('We found no documented reason for ' . $package . ' being blocked.');
- }
-
- return 0;
- }
-
- /**
- * Process any potential matches after a Regex Search for dependency annotations
- *
- * @param array $matches Output of {@see preg_match_all} with PREG_OFFSET_CAPTURE flag set
- * @param InputInterface $input
- * @param string $contents Entire contents of a PHP file
- * @param OutputInterface $output
- * @param string $file Filename
- * @return bool Whether or not any matches were found in the file
- */
- protected function processMatches(
- array $matches,
- InputInterface $input,
- string $contents,
- OutputInterface $output,
- string $file
- ): bool {
- $found = false;
-
- $matchCount = count($matches[0]) ?? 0;
- for ($match = 0; $match < $matchCount; ++$match) {
- $package = strtolower($matches[static::INLINE_MATCH_PACKAGE][$match][0]);
- if ($package !== $input->getArgument('package')) {
- continue;
- }
-
- /** @var string $version */
- $version = $input->getArgument('version');
-
- // @dependency composer/semver:^1|^2|^3 We need the Semver::satisfies static method
- if (Semver::satisfies($version, $matches[static::INLINE_MATCH_VERSION][$match][0])) {
- continue;
- }
-
- $found = true;
-
- $pos = $matches[0][$match][1];
- $line = substr_count(mb_substr($contents, 0, $pos), "\n") + 1;
-
- $reason = trim($matches[static::INLINE_MATCH_REASON][$match][0]) ?? 'No reason provided';
- if (substr($reason, -2) === '*/') {
- $reason = trim(substr($reason, 0, -2));
- }
-
- $output->writeln(
- $file . ':' . $line . ' ' .
- $reason . ' ' .
- '(' . $matches[static::INLINE_MATCH_VERSION][$match][0] . ')'
- );
- }
- return $found;
+ $this->aggregator = $aggregator;
+ $this->outputHandler = $outputHandler;
}
- /**
- * Retrieve all PHP files out of the directories and files listed in the autoload directive
- *
- * @param string $basePath Base directory of the package who's autoload we're processing
- * @param array $autoload Result of {@see PackageInterface::getAutoload()}
- * @param string[] $results Array of file paths to merge with
- * @return string[] File paths
- */
- private static function getAllFilesForAutoload(string $basePath, array $autoload, array $results = []): array
+ public function execute(string $packageToSearchFor, string $versionToCompareTo): int
{
- foreach ($autoload as $map) {
- foreach ($map as $dir) {
- $realDir = realpath($basePath . DIRECTORY_SEPARATOR . $dir);
- if ($realDir === false) {
- continue;
+ $dependencies = $this->aggregator->aggregate();
+
+ /** @var DeclaredDependency[] $failingDependencies Declarations of the provided package that don't match the
+ * version requirement
+ */
+ $failingDependencies = array_filter(
+ $dependencies,
+ static function (DeclaredDependency $attribute) use ($packageToSearchFor, $versionToCompareTo) {
+ if ($attribute->getPackage() === null || $attribute->getConstraint() === null) {
+ return false;
}
- $results = static::getAllPhpFiles($realDir, $results);
- }
- }
- return $results;
- }
-
- /**
- * Find all PHP files by recursively searching a directory
- *
- * @param string $dir Directory to search recursively
- * @param string[] $results Array of file paths to merge with
- * @return string[] File paths
- */
- private static function getAllPhpFiles(string $dir, array $results = []): array
- {
- $files = scandir($dir);
- if ($files === false) {
- return $results;
- }
-
- foreach ($files as $key => $value) {
- $path = realpath($dir . DIRECTORY_SEPARATOR . $value);
- if ($path === false) {
- continue;
- }
-
- if (!is_dir($path) && substr($path, -4) === '.php') {
- $results[] = $path;
- } elseif (!in_array($value, ['.', '..'])) {
- $results = static::getAllPhpFiles($path, $results);
+ return strtolower($attribute->getPackage()) === strtolower($packageToSearchFor)
+ && !Semver::satisfies($versionToCompareTo, $attribute->getConstraint());
}
- }
+ );
- return $results;
+ return $this->outputHandler->output($failingDependencies, $packageToSearchFor, $versionToCompareTo);
}
}
diff --git a/src/Command/WhyBlockCommand/CsvOutputHandler.php b/src/Command/WhyBlockCommand/CsvOutputHandler.php
new file mode 100644
index 0000000..4d1a20d
--- /dev/null
+++ b/src/Command/WhyBlockCommand/CsvOutputHandler.php
@@ -0,0 +1,55 @@
+includeHeader = $includeHeader;
+ $this->writer = $writer;
+ }
+
+ /**
+ * @param DeclaredDependency[] $dependencies
+ * @param string $packageToSearchFor
+ * @param string $versionToCompareTo
+ * @return int
+ */
+ public function output(array $dependencies, string $packageToSearchFor, string $versionToCompareTo): int
+ {
+ if (!$this->writer->canWrite()) {
+ throw new RuntimeException('Unable to output to stdin');
+ }
+ if ($this->includeHeader) {
+ $this->writer->writeCsv(['File', 'Line #', 'Constraint Specified', 'Reasoning']);
+ }
+ foreach ($dependencies as $dependency) {
+ $this->writer->writeCsv(
+ [
+ $dependency->getFile() ?: '',
+ $dependency->getLine() ?: '',
+ $dependency->getConstraint() ?: '',
+ $dependency->getReason() ?: '',
+ ]
+ );
+ }
+ return count($dependencies) < 1 ? 0 : 1;
+ }
+}
diff --git a/src/Command/WhyBlockCommand/JsonOutputHandler.php b/src/Command/WhyBlockCommand/JsonOutputHandler.php
new file mode 100644
index 0000000..9e3cecd
--- /dev/null
+++ b/src/Command/WhyBlockCommand/JsonOutputHandler.php
@@ -0,0 +1,64 @@
+writer = $writer;
+ }
+
+ /**
+ * @param DeclaredDependency[] $dependencies
+ * @param string $packageToSearchFor
+ * @param string $versionToCompareTo
+ * @return int
+ */
+ public function output(array $dependencies, string $packageToSearchFor, string $versionToCompareTo): int
+ {
+ if (!function_exists('json_encode')) {
+ throw new RuntimeException('PHP JSON Extension must be installed to use JSON Output');
+ }
+ if (!$this->writer->canWrite()) {
+ throw new RuntimeException('Unable to output to stdin');
+ }
+ $this->writer->write(
+ json_encode(
+ array_map(
+ static function (DeclaredDependency $dependency) {
+ $result = [];
+ if ($dependency->getFile() !== null) {
+ $result['file'] = $dependency->getFile();
+ }
+ if ($dependency->getLine() !== null) {
+ $result['line'] = $dependency->getLine();
+ }
+ if ($dependency->getConstraint() !== null) {
+ $result['declaredConstraint'] = $dependency->getConstraint();
+ }
+ if ($dependency->getReason() !== null) {
+ $result['reason'] = $dependency->getReason();
+ }
+ return $result;
+ },
+ $dependencies
+ )
+ ) ?: ''
+ );
+ return count($dependencies) < 1 ? 0 : 1;
+ }
+}
diff --git a/src/Command/WhyBlockCommand/OutputHandlerInterface.php b/src/Command/WhyBlockCommand/OutputHandlerInterface.php
new file mode 100644
index 0000000..781233e
--- /dev/null
+++ b/src/Command/WhyBlockCommand/OutputHandlerInterface.php
@@ -0,0 +1,22 @@
+output = $output;
+ }
+
+ /**
+ * @param DeclaredDependency[] $dependencies
+ * @param string $packageToSearchFor
+ * @param string $versionToCompareTo
+ * @return int
+ */
+ public function output(array $dependencies, string $packageToSearchFor, string $versionToCompareTo): int
+ {
+ if (count($dependencies) < 1) {
+ $this->output->writeln("We found no reason to block {$packageToSearchFor} v{$versionToCompareTo}");
+ return 0;
+ }
+
+ foreach ($dependencies as $dependency) {
+ $this->output->writeln(
+ ($dependency->getReference() !== null
+ ? str_replace(getcwd() . '/', '', $dependency->getReference())
+ : 'Unknown File')
+ . ': ' . $dependency->getReason()
+ . ' (' . $dependency->getConstraint() . ')'
+ );
+ }
+ return 1;
+ }
+}
diff --git a/src/Command/WhyBlockCommand/XmlOutputHandler.php b/src/Command/WhyBlockCommand/XmlOutputHandler.php
new file mode 100644
index 0000000..20b4191
--- /dev/null
+++ b/src/Command/WhyBlockCommand/XmlOutputHandler.php
@@ -0,0 +1,65 @@
+writer = $writer;
+ }
+
+ /**
+ * @param DeclaredDependency[] $dependencies
+ * @param string $packageToSearchFor
+ * @param string $versionToCompareTo
+ * @return int
+ */
+ public function output(array $dependencies, string $packageToSearchFor, string $versionToCompareTo): int
+ {
+ if (!class_exists(SimpleXMLElement::class)) {
+ throw new RuntimeException('PHP SimpleXML extension required to use XML Output');
+ }
+ if (!$this->writer->canWrite()) {
+ throw new RuntimeException('Unable to output to stdin');
+ }
+
+ $results = new SimpleXMLElement('');
+ $results->addAttribute('testedPackage', $packageToSearchFor);
+ $results->addAttribute('packageVersion', $versionToCompareTo);
+ foreach ($dependencies as $dependency) {
+ if ($dependency->getReason() !== null) {
+ $reason = $results->addChild('reason', $dependency->getReason());
+ } else {
+ $reason = $results->addChild('reason');
+ }
+ if ($dependency->getFile() !== null) {
+ $reason->addAttribute('file', $dependency->getFile());
+ }
+ if ($dependency->getLine() !== null) {
+ $reason->addAttribute('line', $dependency->getLine());
+ }
+ if ($dependency->getConstraint() !== null) {
+ $reason->addAttribute('constraint', $dependency->getConstraint());
+ }
+ }
+
+ $this->writer->write($results->asXML() ?: '');
+
+ return count($dependencies) < 1 ? 0 : 1;
+ }
+}
diff --git a/src/Controller/Composer/ComposerCommand.php b/src/Controller/Composer/ComposerCommand.php
new file mode 100644
index 0000000..4c2bea1
--- /dev/null
+++ b/src/Controller/Composer/ComposerCommand.php
@@ -0,0 +1,172 @@
+ CsvOutputHandler::class,
+ self::FORMAT_TEXT => StandardOutputHandler::class,
+ self::FORMAT_JSON => JsonOutputHandler::class,
+ self::FORMAT_XML => XmlOutputHandler::class,
+ ];
+
+ // phpcs:disable Generic.Files.LineLength.TooLong -- Attribute support pre PHP 8
+ #[Dependency('symfony/console', '^5', 'Command\'s setName, addArgument and addOption methods as well as InputArgument\'s constants of REQUIRED and VALUE_NONE')]
+ #[Dependency('php-di/php-di', '^6', 'DI\ContainerBuilder::addDefinitions and the existence of the DI\autowire function')]
+ // phpcs:enable Generic.Files.LineLength.TooLong
+ protected function configure(): void
+ {
+ $this->setName('why-block')
+ ->addArgument('package', InputArgument::REQUIRED, 'Package to inspect')
+ ->addArgument('version', InputArgument::REQUIRED, 'Version you want to update it to')
+ ->addOption(
+ self::OUTPUT_FORMAT,
+ ['f'],
+ InputOption::VALUE_OPTIONAL,
+ 'Format to output results in. Accepted values: text, csv, json, xml',
+ 'text'
+ )
+ ->addOption(
+ self::FAIL_ON_ERROR,
+ ['e'],
+ InputOption::VALUE_NONE,
+ 'Immediately fail on parsing errors'
+ )
+ ->addOption(
+ self::LEGACY_ANNOTATION,
+ ['l'],
+ InputOption::VALUE_NONE,
+ 'Include old @dependency/@composerDependency annotations in search'
+ )
+ ->addOption(
+ self::ROOT_DEPS,
+ ['r'],
+ InputOption::VALUE_NONE,
+ 'Search root dependencies for the @dependency annotation'
+ )
+ ->addOption(
+ self::ALL_DEPS,
+ ['a'],
+ InputOption::VALUE_NONE,
+ 'Search all dependencies for the @dependency annotation'
+ );
+ }
+
+ #[Dependency('symfony/console', '^5', 'InputInterface::getOption and OutputInterface::writeln')]
+ protected function execute(
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $packageToSearchFor = $input->getArgument('package');
+ $versionToCompareTo = $input->getArgument('version');
+ $outputFormat = $input->getOption(self::OUTPUT_FORMAT);
+
+ if (!is_string($packageToSearchFor)) {
+ throw new InvalidArgumentException('Only one package is allowed');
+ }
+ if (!is_string($versionToCompareTo)) {
+ throw new InvalidArgumentException('Only one version is allowed');
+ }
+ if (!is_string($outputFormat)) {
+ throw new InvalidArgumentException('Only one output format is allowed');
+ }
+
+ $outputFormat = strtolower($outputFormat);
+ if (!in_array($outputFormat, static::ACCEPTABLE_FORMATS)) {
+ $outputFormat = 'text';
+ }
+
+ if ($input->getOption(static::ALL_DEPS)) {
+ $composerScope = ComposerScopeDeterminer::SCOPE_ALL_DEPENDENCIES;
+ } elseif ($input->getOption(static::ROOT_DEPS)) {
+ $composerScope = ComposerScopeDeterminer::SCOPE_ROOT_DEPENDENCIES;
+ } else {
+ $composerScope = ComposerScopeDeterminer::SCOPE_PROJECT_ONLY;
+ }
+
+ $containerBuilder = new ContainerBuilder();
+ $containerBuilder->addDefinitions(
+ [
+ InputInterface::class => $input,
+ OutputInterface::class => $output,
+ IssueHandlerInterface::class => $input->getOption(static::FAIL_ON_ERROR)
+ ? FailOnIssueHandler::class
+ : NotifyOnIssueHandler::class,
+ Composer::class => $this->getComposer(true),
+ ParserInterface::class => static function (ContainerInterface $container) use ($input) {
+ $parsers = [$container->get(AstParser::class)];
+ if ($input->getOption(static::LEGACY_ANNOTATION)) {
+ $parsers[] = $container->get(LegacyParser::class);
+ }
+ return new ParserPool($parsers);
+ },
+ WriterInterface::class => autowire(StdOutWriter::class),
+ ComposerScopeDeterminer::class => autowire(ComposerScopeDeterminer::class)
+ ->property('scope', $composerScope),
+ ScopeDeterminerInterface::class => autowire(ComposerScopeDeterminer::class),
+ OutputHandlerInterface::class => autowire(static::FORMAT_MAPPER[$outputFormat]),
+ ]
+ );
+ $container = $containerBuilder->build();
+
+ /** @var WhyBlockCommand $command */
+ $command = $container->get(WhyBlockCommand::class);
+ return $command->execute($packageToSearchFor, $versionToCompareTo);
+ }
+}
diff --git a/src/Plugin.php b/src/Controller/Composer/ComposerPlugin.php
similarity index 63%
rename from src/Plugin.php
rename to src/Controller/Composer/ComposerPlugin.php
index 478174c..6c687ff 100644
--- a/src/Plugin.php
+++ b/src/Controller/Composer/ComposerPlugin.php
@@ -1,26 +1,25 @@
file = $file;
+ $this->line = $line;
+ $this->reference = $reference;
+ $this->package = $package;
+ $this->constraint = $constraint;
+ $this->reason = $reason;
+ $this->required = $required;
+ }
+
+ public function getFile(): ?string
+ {
+ return $this->file;
+ }
+
+ public function getLine(): ?string
+ {
+ return $this->line;
+ }
+
+ public function getReference(): ?string
+ {
+ return $this->reference;
+ }
+
+ public function getPackage(): ?string
+ {
+ return $this->package;
+ }
+
+ public function getConstraint(): ?string
+ {
+ return $this->constraint;
+ }
+
+ public function getReason(): ?string
+ {
+ return $this->reason;
+ }
+
+ public function isRequired(): bool
+ {
+ return $this->required;
+ }
+}
diff --git a/src/Data/ReferenceAdder.php b/src/Data/ReferenceAdder.php
new file mode 100644
index 0000000..ddd92de
--- /dev/null
+++ b/src/Data/ReferenceAdder.php
@@ -0,0 +1,24 @@
+getLine(),
+ "{$file}:{$dependency->getLine()}",
+ $dependency->getPackage(),
+ $dependency->getConstraint(),
+ $dependency->getReason()
+ );
+ }
+}
diff --git a/src/DeclaredDependencyAggregator.php b/src/DeclaredDependencyAggregator.php
new file mode 100644
index 0000000..a7a2904
--- /dev/null
+++ b/src/DeclaredDependencyAggregator.php
@@ -0,0 +1,74 @@
+parser = $parser;
+ $this->scopeDeterminer = $scopeDeterminer;
+ $this->issueHandler = $issueHandler;
+ $this->referenceAdder = $referenceAdder;
+ }
+
+ /**
+ * @return DeclaredDependency[]
+ */
+ #[Pure]
+ public function aggregate(): array
+ {
+ $dependencies = [[]];
+ $files = $this->scopeDeterminer->getFiles();
+ foreach ($files as $file) {
+ $contents = @file_get_contents($file);
+ if ($contents === false) {
+ $this->handleIssue("Could not read from file '{$file}'");
+ continue;
+ }
+ $dependencies[] = array_map(
+ function (DeclaredDependency $dependency) use ($file) {
+ return $this->referenceAdder->add($dependency, $file);
+ },
+ $this->parser->parse($contents)
+ );
+ }
+ return array_merge(...$dependencies);
+ }
+
+ private function handleIssue(string $description): void
+ {
+ if ($this->issueHandler) {
+ $this->issueHandler->execute($description);
+ }
+ }
+}
diff --git a/src/Factory/CollectingFactory.php b/src/Factory/CollectingFactory.php
new file mode 100644
index 0000000..d035324
--- /dev/null
+++ b/src/Factory/CollectingFactory.php
@@ -0,0 +1,37 @@
+container = $container;
+ }
+
+ /**
+ * @throws DependencyException
+ * @throws NotFoundException
+ */
+ public function create(): Collecting
+ {
+ return $this->container->make(Collecting::class);
+ }
+}
diff --git a/src/Factory/FindingVisitorFactory.php b/src/Factory/FindingVisitorFactory.php
new file mode 100644
index 0000000..663e6c0
--- /dev/null
+++ b/src/Factory/FindingVisitorFactory.php
@@ -0,0 +1,38 @@
+container = $container;
+ }
+
+ /**
+ * @param mixed[] $args Arguments for {@link FindingVisitor}'s constructor
+ * @throws DependencyException
+ * @throws NotFoundException
+ */
+ public function create(array $args = []): FindingVisitor
+ {
+ return $this->container->make(FindingVisitor::class, $args);
+ }
+}
diff --git a/src/Factory/NodeTraverserFactory.php b/src/Factory/NodeTraverserFactory.php
new file mode 100644
index 0000000..b5f7f0f
--- /dev/null
+++ b/src/Factory/NodeTraverserFactory.php
@@ -0,0 +1,38 @@
+container = $container;
+ }
+
+ /**
+ * @param mixed[] $args Arguments for {@link NodeTraverser}'s constructor
+ * @throws DependencyException
+ * @throws NotFoundException
+ */
+ public function create(array $args = []): NodeTraverser
+ {
+ return $this->container->make(NodeTraverser::class, $args);
+ }
+}
diff --git a/src/IssueHandler/FailOnIssueHandler.php b/src/IssueHandler/FailOnIssueHandler.php
new file mode 100644
index 0000000..09b9373
--- /dev/null
+++ b/src/IssueHandler/FailOnIssueHandler.php
@@ -0,0 +1,19 @@
+output = $output;
+ }
+
+ #[Dependency('symfony/console', '^5', 'OutputInterface->getErrorOutput and ->writeln')]
+ #[Dependency('symfony/console', '^5', 'ConsoleOutputInterface\'s existence')]
+ public function execute(
+ string $description
+ ): void {
+ $output = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() : $this->output;
+ $output->writeln("{$description}");
+ }
+}
diff --git a/src/Parser/AstParser.php b/src/Parser/AstParser.php
new file mode 100644
index 0000000..fa45d16
--- /dev/null
+++ b/src/Parser/AstParser.php
@@ -0,0 +1,136 @@
+parserFactory = $parserFactory;
+ $this->nameResolver = $nameResolver;
+ $this->nodeTraverserFactory = $nodeTraverserFactory;
+ $this->errorCollectorFactory = $errorCollectorFactory;
+ $this->findingVisitorFactory = $findingVisitorFactory;
+ $this->issueHandler = $issueHandler;
+ }
+
+ #[Dependency('nikic/php-parser', '^4')]
+ #[Dependency('navarr/attribute-dependency', '^1', 'Existence of Dependency attribute')]
+ public function parse(
+ string $contents
+ ): array {
+ $astParser = $this->parserFactory->create(ParserFactory::PREFER_PHP7);
+ $nameResolver = $this->nameResolver;
+ $finder = $this->findingVisitorFactory->create(
+ [
+ 'filterCallback' => static function (Node $node) {
+ return $node instanceof Attribute
+ && $node->name->toString() === Dependency::class;
+ },
+ ]
+ );
+
+ $traverser = $this->nodeTraverserFactory->create();
+ $traverser->addVisitor($nameResolver);
+ $traverser->addVisitor($finder);
+
+ $errorCollector = $this->errorCollectorFactory->create();
+
+ $ast = $astParser->parse($contents, $errorCollector);
+ if ($ast === null || $errorCollector->hasErrors()) {
+ $description = "Could not parse contents:" . PHP_EOL . ' - '
+ . implode(PHP_EOL . ' - ', $errorCollector->getErrors());
+ $this->handleIssue($description);
+ return [];
+ }
+
+ $traverser->traverse($ast);
+
+ $attributes = $finder->getFoundNodes();
+
+ $argIndex = [
+ 0 => 'package',
+ 1 => 'versionConstraint',
+ 2 => 'reason',
+ 3 => 'required',
+ ];
+
+ return array_filter(
+ array_map(
+ static function (Attribute $node) use ($argIndex): ?DeclaredDependency {
+ $attributes = [];
+ foreach ($node->args as $i => $arg) {
+ $name = $arg->name->name ?? $argIndex[$i];
+ if (!is_string($name)) {
+ return null;
+ }
+ if ($arg->value instanceof Node\Scalar\String_) {
+ $attributes[$name] = $arg->value->value;
+ }
+ }
+ if (!isset($attributes['package'])) {
+ return null;
+ }
+ return new DeclaredDependency(
+ null,
+ (string)$node->getLine(),
+ null,
+ $attributes['package'],
+ $attributes['versionConstraint'] ?? null,
+ $attributes['reason'] ?? null,
+ isset($attributes['required']) && (bool)$attributes['required']
+ );
+ },
+ $attributes
+ )
+ );
+ }
+
+ private function handleIssue(string $description): void
+ {
+ if ($this->issueHandler !== null) {
+ $this->issueHandler->execute($description);
+ }
+ }
+}
diff --git a/src/Parser/LegacyParser.php b/src/Parser/LegacyParser.php
new file mode 100644
index 0000000..f6c5e29
--- /dev/null
+++ b/src/Parser/LegacyParser.php
@@ -0,0 +1,93 @@
+issueHandler = $issueHandler;
+ }
+
+ public function parse(string $contents): array
+ {
+ // We can leave this as an empty array b/c processMatches returns _at least_ an empty array
+ // Otherwise, array_merge will fail
+ $results = [];
+
+ // Double slash comments
+ $result = @preg_match_all(
+ '#(^\h*\*+|\h*//|\h*/\*+)\s+' .
+ '@(dependency|composerDependency)\h+' .
+ '([\w/-]+)' .
+ '(:([!~\d^|&,<>=\-\w.]+))?' .
+ '(\h+([^\v]+))?#im',
+ $contents,
+ $matches,
+ PREG_OFFSET_CAPTURE
+ );
+ if ($result === false) {
+ $this->handleIssue('Regex error: ' . preg_last_error_msg());
+ }
+ $results[] = $this->processMatches($matches, $contents);
+
+ return array_merge(...$results);
+ }
+
+ /**
+ * @param string[][] $matches
+ * @param string $contents
+ * @return DeclaredDependency[]
+ */
+ private function processMatches(array $matches, string $contents): array
+ {
+ $results = [];
+
+ $matchCount = count($matches[0]) ?? 0;
+ for ($match = 0; $match < $matchCount; ++$match) {
+ $package = strtolower($matches[static::INLINE_MATCH_PACKAGE][$match][0]);
+ $version = $matches[static::INLINE_MATCH_VERSION][$match][0];
+
+ $line = substr_count(mb_substr($contents, 0, (int)$matches[0][$match][1]), "\n") + 1;
+
+ $reason = trim($matches[static::INLINE_MATCH_REASON][$match][0]) ?? 'No reason provided';
+ if (substr($reason, -2) === '*/') {
+ $reason = trim(substr($reason, 0, -2));
+ }
+
+ $results[] = new DeclaredDependency(
+ null,
+ (string)$line,
+ null,
+ $package,
+ $version,
+ $reason
+ );
+ }
+
+ return $results;
+ }
+
+ private function handleIssue(string $description): void
+ {
+ if ($this->issueHandler !== null) {
+ $this->issueHandler->execute($description);
+ }
+ }
+}
diff --git a/src/Parser/ParserInterface.php b/src/Parser/ParserInterface.php
new file mode 100644
index 0000000..ae48c64
--- /dev/null
+++ b/src/Parser/ParserInterface.php
@@ -0,0 +1,19 @@
+=7', 'Throwing TypeError')]
+ public function __construct(
+ array $parsers = []
+ ) {
+ array_walk(
+ $parsers,
+ static function ($parser) {
+ if (!$parser instanceof ParserInterface) {
+ throw new TypeError('All parsers must implement ' . ParserInterface::class);
+ }
+ }
+ );
+ $this->parsers = $parsers;
+ }
+
+ public function parse(string $contents): array
+ {
+ $result = [[]];
+ foreach ($this->parsers as $parser) {
+ $result[] = $parser->parse($contents);
+ }
+
+ return array_merge(...$result);
+ }
+}
diff --git a/src/Proxy/MimeDeterminer.php b/src/Proxy/MimeDeterminer.php
new file mode 100644
index 0000000..02d563e
--- /dev/null
+++ b/src/Proxy/MimeDeterminer.php
@@ -0,0 +1,29 @@
+resource = $resource;
+ }
+
+ public function canWrite(): bool
+ {
+ return $this->resource !== false;
+ }
+
+ public function write(string $data): void
+ {
+ if ($this->resource) {
+ fputs($this->resource, $data);
+ }
+ }
+
+ /**
+ * @param string[] $data
+ */
+ public function writeCsv(array $data): void
+ {
+ if ($this->resource) {
+ fputcsv($this->resource, $data);
+ }
+ }
+}
diff --git a/src/Proxy/WriterInterface.php b/src/Proxy/WriterInterface.php
new file mode 100644
index 0000000..8457572
--- /dev/null
+++ b/src/Proxy/WriterInterface.php
@@ -0,0 +1,19 @@
+composer = $composer;
+ $this->phpFileDeterminer = $phpFileDeterminer;
+ $this->scope = $scope;
+ }
+
+ #[Pure]
+ #[Dependency('composer/composer', '^2', 'Composer primary class getPackage')]
+ #[Dependency('composer/composer', '^2', 'RootPackageInterface getAutoload and getRequires')]
+ public function getFiles(): array
+ {
+ $package = $this->composer->getPackage();
+
+ $autoload = $package->getAutoload();
+ $files = $this->getAllFilesForAutoload('.', $autoload);
+
+ /** @var Link[] $packages */
+ $packages = [];
+ if ($this->scope >= static::SCOPE_ROOT_DEPENDENCIES) {
+ $packages = $package->getRequires();
+ }
+
+ if ($this->scope >= static::SCOPE_ALL_DEPENDENCIES) {
+ for ($i = 0; $i < count($packages); ++$i) {
+ $package = $packages[$i];
+ $requirements = $this->getRequirementsForPackage($package);
+
+ // I wonder if this is better or worse than recursive
+ $packages = array_merge(
+ $packages,
+ array_diff($requirements, $packages)
+ );
+ }
+ }
+
+ foreach ($packages as $package) {
+ $path = 'vendor' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $package->getTarget());
+
+ $foundPackage = $this->composer->getRepositoryManager()
+ ->getLocalRepository()
+ ->findPackage($package->getTarget(), $package->getConstraint());
+
+ if (!$foundPackage) {
+ continue;
+ }
+
+ $this->getAllFilesForAutoload(
+ $path,
+ $foundPackage->getAutoload(),
+ $files
+ );
+ }
+
+ return array_unique($files);
+ }
+
+ /**
+ * Get all packages required by a package
+ *
+ * @param Link $package
+ * @return Link[]
+ */
+ private function getRequirementsForPackage(Link $package): array
+ {
+ $package = $this->composer->getRepositoryManager()
+ ->getLocalRepository()
+ ->findPackage($package->getTarget(), $package->getConstraint());
+
+ return $package === null ? [] : $package->getRequires();
+ }
+
+ /**
+ * Retrieve all PHP files out of the directories and files listed in the autoload directive
+ *
+ * @param string $basePath Base directory of the package who's autoload we're processing
+ * @param array $autoload Result of {@see PackageInterface::getAutoload()}
+ * @param string[] $results Array of file paths to merge with
+ * @return string[] File paths
+ */
+ #[Pure]
+ private function getAllFilesForAutoload(
+ string $basePath,
+ array $autoload,
+ array $results = []
+ ): array {
+ foreach ($autoload as $map) {
+ foreach ($map as $dir) {
+ $realDir = realpath($basePath . DIRECTORY_SEPARATOR . $dir);
+ if ($realDir === false) {
+ continue;
+ }
+ if (is_file($realDir)) {
+ $results[] = $realDir;
+ continue;
+ }
+ if (is_dir($realDir)) {
+ $results = $this->getAllPhpFiles($realDir, $results);
+ }
+ }
+ }
+ return $results;
+ }
+
+ /**
+ * Find all PHP files by recursively searching a directory
+ *
+ * @param string $dir Directory to search recursively
+ * @param string[] $results Array of file paths to merge with
+ * @return string[] File paths
+ */
+ private function getAllPhpFiles(string $dir, array $results = []): array
+ {
+ $files = scandir($dir);
+ if ($files === false) {
+ return $results;
+ }
+
+ foreach ($files as $value) {
+ $path = realpath($dir . DIRECTORY_SEPARATOR . $value);
+ if ($path === false) {
+ continue;
+ }
+
+ if (is_file($path) && $this->phpFileDeterminer->isPhp($path)) {
+ $results[] = $path;
+ } elseif (is_dir($path) && !in_array($value, ['.', '..'])) {
+ $results = $this->getAllPhpFiles($path, $results);
+ }
+ }
+
+ return $results;
+ }
+}
diff --git a/src/ScopeDeterminer/PhpFileDeterminer.php b/src/ScopeDeterminer/PhpFileDeterminer.php
new file mode 100644
index 0000000..ad8af7b
--- /dev/null
+++ b/src/ScopeDeterminer/PhpFileDeterminer.php
@@ -0,0 +1,61 @@
+mimeDeterminer = $mimeDeterminer;
+ }
+
+ #[Pure]
+ public function isPhp(
+ string $file
+ ): bool {
+ // There are so many approaches we could take here, but we're going with this one:
+
+ $mimeType = $this->mimeDeterminer->getMimeType($file);
+ if ($mimeType && in_array($mimeType, static::PHP_MIME_TYPES)) {
+ // Mime type is PHP. That's good enough for me
+ return true;
+ }
+
+ $parts = explode('.', $file);
+ if (in_array(end($parts), static::PHP_FILE_EXTENSIONS)) {
+ // Extension matches list - so it was probably intended to be PHP
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/ScopeDeterminer/ScopeDeterminerInterface.php b/src/ScopeDeterminer/ScopeDeterminerInterface.php
new file mode 100644
index 0000000..30406e5
--- /dev/null
+++ b/src/ScopeDeterminer/ScopeDeterminerInterface.php
@@ -0,0 +1,18 @@
+ 'No error',
+ PREG_INTERNAL_ERROR => 'Internal Error',
+ PREG_BACKTRACK_LIMIT_ERROR => 'Backtrack limit exhausted',
+ PREG_RECURSION_LIMIT_ERROR => 'Recursion limit exhausted',
+ PREG_BAD_UTF8_ERROR => 'Malformed UTF-8 characters, possibly incorrectly encoded',
+ PREG_BAD_UTF8_OFFSET_ERROR => 'The offset did not correspond to the beginning of a valid UTF-8 code point',
+ PREG_JIT_STACKLIMIT_ERROR => 'JIT stack limit exhausted',
+ ][preg_last_error()];
+ }
+}
diff --git a/tests/Command/WhyBlockCommand/AbstractOutputHandlerTest.php b/tests/Command/WhyBlockCommand/AbstractOutputHandlerTest.php
new file mode 100644
index 0000000..b6dd9ea
--- /dev/null
+++ b/tests/Command/WhyBlockCommand/AbstractOutputHandlerTest.php
@@ -0,0 +1,58 @@
+container = new Container();
+ }
+
+ /**
+ * @param mixed[] $args Arguments to pass to constructor (may be named)
+ * @return OutputHandlerInterface
+ */
+ abstract protected function createHandler(array $args = []): OutputHandlerInterface;
+
+ /**
+ * @return StdOutWriter&MockObject
+ */
+ protected function defaultWriterMock()
+ {
+ $writer = $this->createMock(StdOutWriter::class);
+ $writer->method('canWrite')
+ ->willReturn(true);
+
+ return $writer;
+ }
+
+ public function testReturnValueIsZeroWhenThereAreNoDependencies(): void
+ {
+ $handler = $this->createHandler();
+ $this->assertEquals(0, $handler->output([], '', ''));
+ }
+
+ public function testReturnValueIsOneWhenThereAreDependencies(): void
+ {
+ $handler = $this->createHandler();
+ $this->assertEquals(1, $handler->output([new DeclaredDependency()], '', ''));
+ }
+}
diff --git a/tests/Command/WhyBlockCommand/CsvOutputHandlerTest.php b/tests/Command/WhyBlockCommand/CsvOutputHandlerTest.php
new file mode 100644
index 0000000..0de0268
--- /dev/null
+++ b/tests/Command/WhyBlockCommand/CsvOutputHandlerTest.php
@@ -0,0 +1,98 @@
+defaultWriterMock();
+ }
+ return $this->container->make(CsvOutputHandler::class, $args);
+ }
+
+ public function testOutputHeaderIsNotWrittenWhenIncludeHeaderIsFalse(): void
+ {
+ $writer = $this->defaultWriterMock();
+ $writer->expects($this->never())
+ ->method('writeCsv');
+
+ $handler = $this->createHandler(['writer' => $writer, 'includeHeader' => false]);
+ $handler->output([], '', '');
+ }
+
+ public function testHeaderIsWrittenByDefault(): void
+ {
+ $writer = $this->defaultWriterMock();
+ $writer->expects($this->atLeastOnce())
+ ->method('writeCsv')
+ ->with(['File', 'Line #', 'Constraint Specified', 'Reasoning']);
+
+ $handler = $this->container->make(CsvOutputHandler::class, ['writer' => $writer]);
+ $handler->output([], '', '');
+ }
+
+ public function testExceptionWhenCanWriteReturnsFalse(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Unable to output to stdin');
+
+ $mock = $this->createMock(WriterInterface::class);
+ $mock->expects($this->once())
+ ->method('canWrite')
+ ->willReturn(false);
+
+ $handler = $this->createHandler(['writer' => $mock]);
+ $handler->output([], '', '');
+ }
+
+ public function testOutputHeaderIsWrittenWhenIncludeHeaderIsTrue(): void
+ {
+ $writer = $this->defaultWriterMock();
+ $writer->expects($this->atLeastOnce())
+ ->method('writeCsv')
+ ->with(['File', 'Line #', 'Constraint Specified', 'Reasoning']);
+
+ $handler = $this->createHandler(['writer' => $writer, 'includeHeader' => true]);
+ $handler->output([], '', '');
+ }
+
+ public function testCsvOutputIsInExpectedFormat(): void
+ {
+ $file = uniqid();
+ $line = uniqid();
+ $constraint = uniqid();
+ $reason = uniqid();
+
+ $writer = $this->defaultWriterMock();
+ $writer->expects($this->once())
+ ->method('writeCsv')
+ ->with([$file, $line, $constraint, $reason]);
+
+ $dependency = new DeclaredDependency(
+ file: $file,
+ line: $line,
+ constraint: $constraint,
+ reason: $reason
+ );
+
+ $handler = $this->createHandler(['writer' => $writer, 'includeHeader' => false]);
+ $handler->output([$dependency], '', '');
+ }
+}
diff --git a/tests/Command/WhyBlockCommand/JsonOutputHandlerTest.php b/tests/Command/WhyBlockCommand/JsonOutputHandlerTest.php
new file mode 100644
index 0000000..7c40e10
--- /dev/null
+++ b/tests/Command/WhyBlockCommand/JsonOutputHandlerTest.php
@@ -0,0 +1,88 @@
+defaultWriterMock();
+ }
+
+ return $this->container->make(JsonOutputHandler::class, $args);
+ }
+
+ public function testExceptionWhenCanWriteReturnsFalse(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Unable to output to stdin');
+
+ $mock = $this->createMock(WriterInterface::class);
+ $mock->expects($this->once())
+ ->method('canWrite')
+ ->willReturn(false);
+
+ $handler = $this->createHandler(['writer' => $mock]);
+ $handler->output([], '', '');
+ }
+
+ public function expectedResultProvider(): array
+ {
+ $file = uniqid();
+ $line = uniqid();
+ $constraint = uniqid();
+ $reason = uniqid();
+
+ return [
+ 'No Results' => ['[]', []],
+ 'Only File' => [json_encode([['file' => $file]]), [new DeclaredDependency(file: $file)]],
+ 'Only Line' => [json_encode([['line' => $line]]), [new DeclaredDependency(line: $line)]],
+ 'Only Constraint' => [
+ json_encode([['declaredConstraint' => $constraint]]),
+ [new DeclaredDependency(constraint: $constraint)],
+ ],
+ 'Only Reason' => [json_encode([['reason' => $reason]]), [new DeclaredDependency(reason: $reason)]],
+ 'All in order' => [
+ json_encode(
+ [
+ [
+ 'file' => $file,
+ 'line' => $line,
+ 'declaredConstraint' => $constraint,
+ 'reason' => $reason,
+ ],
+ ]
+ ),
+ [new DeclaredDependency(file: $file, line: $line, constraint: $constraint, reason: $reason)],
+ ],
+ ];
+ }
+
+ /**
+ * @param string $expectedResult
+ * @param DeclaredDependency[] $dependencies
+ * @dataProvider expectedResultProvider
+ */
+ public function testOutputFormatIsAsExpected(string $expectedResult, array $dependencies = []): void
+ {
+ $writer = $this->defaultWriterMock();
+ $writer->expects($this->once())
+ ->method('write')
+ ->with($expectedResult);
+
+ $handler = $this->createHandler(['writer' => $writer]);
+ $handler->output($dependencies, '', '');
+ }
+}
diff --git a/tests/Command/WhyBlockCommand/StandardOutputHandlerTest.php b/tests/Command/WhyBlockCommand/StandardOutputHandlerTest.php
new file mode 100644
index 0000000..fe98a8f
--- /dev/null
+++ b/tests/Command/WhyBlockCommand/StandardOutputHandlerTest.php
@@ -0,0 +1,99 @@
+createMock(OutputInterface::class);
+ }
+ return $this->container->make(StandardOutputHandler::class, $args);
+ }
+
+ public function testHumanReadableOutputWhenNoResults(): void
+ {
+ $packageToSearchFor = uniqid();
+ $versionToCompareTo = uniqid();
+
+ $output = $this->createMock(OutputInterface::class);
+ $output->expects($this->once())
+ ->method('writeln')
+ ->with("We found no reason to block {$packageToSearchFor} v{$versionToCompareTo}");
+
+ $handler = $this->createHandler(['output' => $output]);
+ $handler->output([], $packageToSearchFor, $versionToCompareTo);
+ }
+
+ public function testGeneralOutputFormat(): void
+ {
+ $reference = uniqid();
+ $constraint = uniqid();
+ $reason = uniqid();
+
+ $dependency = new DeclaredDependency(
+ reference: $reference,
+ constraint: $constraint,
+ reason: $reason
+ );
+
+ $output = $this->createMock(OutputInterface::class);
+ $output->expects($this->once())
+ ->method('writeln')
+ ->with("{$reference}: {$reason} ({$constraint})");
+
+ $handler = $this->createHandler(['output' => $output]);
+ $handler->output([$dependency], '', '');
+ }
+
+ public function testReferencelessOutputFormat(): void
+ {
+ $constraint = uniqid();
+ $reason = uniqid();
+
+ $dependency = new DeclaredDependency(
+ constraint: $constraint,
+ reason: $reason
+ );
+
+ $output = $this->createMock(OutputInterface::class);
+ $output->expects($this->once())
+ ->method('writeln')
+ ->with("Unknown File: {$reason} ({$constraint})");
+
+ $handler = $this->createHandler(['output' => $output]);
+ $handler->output([$dependency], '', '');
+ }
+
+ public function testCurrentWorkingDirectoryIsRemovedFromReference(): void
+ {
+ $reference = uniqid();
+ $constraint = uniqid();
+ $reason = uniqid();
+
+ $dependency = new DeclaredDependency(
+ reference: getcwd() . '/' .$reference,
+ constraint: $constraint,
+ reason: $reason
+ );
+
+ $output = $this->createMock(OutputInterface::class);
+ $output->expects($this->once())
+ ->method('writeln')
+ ->with("{$reference}: {$reason} ({$constraint})");
+
+ $handler = $this->createHandler(['output' => $output]);
+ $handler->output([$dependency], '', '');
+ }
+}
diff --git a/tests/Command/WhyBlockCommand/XmlOutputHandler/ContainerWriter.php b/tests/Command/WhyBlockCommand/XmlOutputHandler/ContainerWriter.php
new file mode 100644
index 0000000..bb7879d
--- /dev/null
+++ b/tests/Command/WhyBlockCommand/XmlOutputHandler/ContainerWriter.php
@@ -0,0 +1,48 @@
+content .= $data;
+ }
+
+ /**
+ * @param string[] $data
+ */
+ public function writeCsv(array $data): void
+ {
+ $resource = fopen('php://memory', 'r+');
+ if ($resource === false) {
+ throw new RuntimeException('could not open memory for read/write');
+ }
+ fputcsv($resource, $data);
+ rewind($resource);
+ $this->content .= stream_get_contents($resource);
+ fclose($resource);
+ }
+
+ public function getContents(): string
+ {
+ return $this->content;
+ }
+}
diff --git a/tests/Command/WhyBlockCommand/XmlOutputHandlerTest.php b/tests/Command/WhyBlockCommand/XmlOutputHandlerTest.php
new file mode 100644
index 0000000..5c057e2
--- /dev/null
+++ b/tests/Command/WhyBlockCommand/XmlOutputHandlerTest.php
@@ -0,0 +1,164 @@
+defaultWriterMock();
+ }
+
+ return $this->container->make(XmlOutputHandler::class, $args);
+ }
+
+ public function testExceptionWhenCanWriteReturnsFalse(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('Unable to output to stdin');
+
+ $mock = $this->createMock(WriterInterface::class);
+ $mock->expects($this->once())
+ ->method('canWrite')
+ ->willReturn(false);
+
+ $handler = $this->createHandler(['writer' => $mock]);
+ $handler->output([], '', '');
+ }
+
+ #[ArrayShape([
+ 'xml' => SimpleXMLElement::class,
+ 'dep1' => ['string','string','string','string'],
+ 'dep2' => ['string','string','string','string']
+ ])]
+ public function testRootElementOfResponseIsBlockReasonsAndHasExpectedAttributes(): array
+ {
+ $packageToSearchFor = uniqid();
+ $versionToCompareTo = uniqid();
+
+ $writer = new ContainerWriter();
+
+ $reason1 = uniqid();
+ $file1 = uniqid();
+ $line1 = uniqid();
+ $constraint1 = uniqid();
+ $reason2 = uniqid();
+ $file2 = uniqid();
+ $line2 = uniqid();
+ $constraint2 = uniqid();
+
+ $dependency1 = new DeclaredDependency(
+ file: $file1,
+ line: $line1,
+ constraint: $constraint1,
+ reason: $reason1
+ );
+
+ $dependency2 = new DeclaredDependency(
+ file: $file2,
+ line: $line2,
+ constraint: $constraint2,
+ reason: $reason2
+ );
+
+ $handler = $this->createHandler(['writer' => $writer]);
+ $handler->output([$dependency1, $dependency2], $packageToSearchFor, $versionToCompareTo);
+
+ $xml = new SimpleXMLElement($writer->getContents());
+ $this->assertEquals('block-reasons', $xml->getName());
+ $this->assertEquals($packageToSearchFor, $xml['testedPackage']);
+ $this->assertEquals($versionToCompareTo, $xml['packageVersion']);
+
+ return [
+ 'xml' => $xml,
+ 'dep1' => [$file1, $line1, $constraint1, $reason1],
+ 'dep2' => [$file2, $line2, $constraint2, $reason2],
+ ];
+ }
+
+ /**
+ * @depends testRootElementOfResponseIsBlockReasonsAndHasExpectedAttributes
+ */
+ public function testReasonTagExistsForEachDependency(
+ #[ArrayShape([
+ 'xml' => SimpleXMLElement::class,
+ 'dep1' => ['string', 'string', 'string', 'string'],
+ 'dep2' => ['string', 'string', 'string', 'string'],
+ ])] array $result
+ ): void {
+ $xml = $result['xml'];
+
+ $children = $xml->children();
+ $this->assertCount(2, $children);
+ }
+
+ /**
+ * @depends testRootElementOfResponseIsBlockReasonsAndHasExpectedAttributes
+ */
+ public function testDependency1ContentIsInReasons(
+ #[ArrayShape([
+ 'xml' => SimpleXMLElement::class,
+ 'dep1' => ['string', 'string', 'string', 'string'],
+ 'dep2' => ['string', 'string', 'string', 'string'],
+ ])] array $result
+ ): void {
+ $xml = $result['xml'];
+ list($file, $line, $constraint, $reason) = $result['dep1'];
+
+ $this->assertCount(3, $xml->reason->attributes());
+ $this->assertEquals($reason, (string)$xml->reason[0]);
+ $this->assertEquals($file, (string)$xml->reason[0]['file']);
+ $this->assertEquals($line, (string)$xml->reason[0]['line']);
+ $this->assertEquals($constraint, (string)$xml->reason[0]['constraint']);
+ }
+
+ /**
+ * @depends testRootElementOfResponseIsBlockReasonsAndHasExpectedAttributes
+ */
+ public function testDependency2ContentIsInReasons(
+ #[ArrayShape([
+ 'xml' => SimpleXMLElement::class,
+ 'dep1' => ['string', 'string', 'string', 'string'],
+ 'dep2' => ['string', 'string', 'string', 'string'],
+ ])] array $result
+ ): void {
+ $xml = $result['xml'];
+ list($file, $line, $constraint, $reason) = $result['dep2'];
+
+ $this->assertCount(3, $xml->reason->attributes());
+ $this->assertEquals($reason, (string)$xml->reason[1]);
+ $this->assertEquals($file, (string)$xml->reason[1]['file']);
+ $this->assertEquals($line, (string)$xml->reason[1]['line']);
+ $this->assertEquals($constraint, (string)$xml->reason[1]['constraint']);
+ }
+
+ public function testAttributesAreExcludedWhenNotInDependency()
+ {
+ $dependency = new DeclaredDependency();
+ $writer = new ContainerWriter();
+ $handler = new XmlOutputHandler($writer);
+
+ $handler->output([$dependency], '', '');
+
+ $blankXml = <<
+
+
+XML;
+
+ $this->assertEquals($blankXml, $writer->getContents());
+ }
+}
diff --git a/tests/Command/WhyBlockCommandTest.php b/tests/Command/WhyBlockCommandTest.php
new file mode 100644
index 0000000..8aa0dc8
--- /dev/null
+++ b/tests/Command/WhyBlockCommandTest.php
@@ -0,0 +1,221 @@
+createMock(DeclaredDependencyAggregator::class);
+ $aggregatorMock->expects($this->once())
+ ->method('aggregate')
+ ->willReturn($dependencies);
+
+ return $aggregatorMock;
+ }
+
+ /**
+ * @param string $package
+ * @param string $version
+ * @param DeclaredDependency[] $expectedResult Defaults to empty
+ * @return OutputHandlerInterface&MockObject
+ */
+ private function createOutputHandlerMock(string $package, string $version, array $expectedResult = [])
+ {
+ $outputHandlerMock = $this->createMock(OutputHandlerInterface::class);
+ $outputHandlerMock->expects($this->once())
+ ->method('output')
+ ->with($expectedResult, $package, $version);
+
+ return $outputHandlerMock;
+ }
+
+ public function testNonTargetedPackagesAreNotReturned(): void
+ {
+ $constraint = '>=100';
+
+ $dependency = new DeclaredDependency(
+ package: static::TEST_PACKAGE_UNUSED,
+ constraint: $constraint
+ );
+
+ $failingVersion = '5';
+
+ $this->assertFalse(Semver::satisfies($failingVersion, $constraint));
+
+ $aggregatorMock = $this->createAggregatorMock([$dependency]);
+ $outputHandlerMock = $this->createOutputHandlerMock(static::TEST_PACKAGE, $failingVersion);
+
+ $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock);
+ $command->execute(static::TEST_PACKAGE, $failingVersion);
+ }
+
+ public function testSatisfactoryConstraintsAreNotReturned(): void
+ {
+ $constraint = '<100';
+
+ $dependency = new DeclaredDependency(
+ package: static::TEST_PACKAGE,
+ constraint: $constraint
+ );
+
+ $testVersion = '5';
+
+ $this->assertTrue(Semver::satisfies($testVersion, $constraint));
+
+ $aggregatorMock = $this->createAggregatorMock([$dependency]);
+
+ $outputHandler = $this->createOutputHandlerMock(static::TEST_PACKAGE, $testVersion);
+
+ $command = new WhyBlockCommand($aggregatorMock, $outputHandler);
+ $command->execute(static::TEST_PACKAGE, $testVersion);
+ }
+
+ public function testDependencyWithoutPackageIsNotReturned(): void
+ {
+ $constraint = '>=100';
+
+ $dependency = new DeclaredDependency(
+ constraint: $constraint
+ );
+
+ $failingVersion = '5';
+
+ $this->assertFalse(Semver::satisfies($failingVersion, $constraint));
+
+ $aggregatorMock = $this->createAggregatorMock([$dependency]);
+ $outputHandlerMock = $this->createOutputHandlerMock(static::TEST_PACKAGE, $failingVersion);
+
+ $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock);
+ $command->execute(static::TEST_PACKAGE, $failingVersion);
+ }
+
+ public function testDependencyWithoutConstraintIsNotReturned(): void
+ {
+ $constraint = '>=100';
+
+ $dependency = new DeclaredDependency(
+ package: static::TEST_PACKAGE
+ );
+
+ $failingVersion = '5';
+
+ $this->assertFalse(Semver::satisfies($failingVersion, $constraint));
+
+ $aggregatorMock = $this->createAggregatorMock([$dependency]);
+ $outputHandlerMock = $this->createOutputHandlerMock(static::TEST_PACKAGE, $failingVersion);
+
+ $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock);
+ $command->execute(static::TEST_PACKAGE, $failingVersion);
+ }
+
+ public function testDependenciesFailingConstraintsAreReturned(): void
+ {
+ $failingConstraint = '>=100';
+
+ $dependency1 = new DeclaredDependency(
+ package: static::TEST_PACKAGE,
+ constraint: $failingConstraint,
+ reason: uniqid()
+ );
+
+ $dependency2 = new DeclaredDependency(
+ package: static::TEST_PACKAGE,
+ constraint: $failingConstraint,
+ reason: uniqid()
+ );
+
+ $failingVersion = '5';
+
+ $this->assertFalse(Semver::satisfies($failingVersion, $failingConstraint));
+
+ $aggregatorMock = $this->createAggregatorMock([$dependency1, $dependency2]);
+ $outputHandlerMock = $this->createOutputHandlerMock(
+ static::TEST_PACKAGE,
+ $failingVersion,
+ [$dependency1, $dependency2]
+ );
+
+ $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock);
+ $command->execute(static::TEST_PACKAGE, $failingVersion);
+ }
+
+ public function testLowercaseVersionOfPackageNamesIsUsedForMatch(): void
+ {
+ $failingConstraint = '>=100';
+
+ $dependency1 = new DeclaredDependency(
+ package: 'navarr/example-PACKAGE',
+ constraint: $failingConstraint,
+ reason: uniqid()
+ );
+
+ $dependency2 = new DeclaredDependency(
+ package: 'navarr/EXAMPLE-package',
+ constraint: $failingConstraint,
+ reason: uniqid()
+ );
+
+ $failingVersion = '5';
+
+ $this->assertFalse(Semver::satisfies($failingVersion, $failingConstraint));
+
+ $aggregatorMock = $this->createAggregatorMock([$dependency1, $dependency2]);
+ $outputHandlerMock = $this->createOutputHandlerMock(
+ 'NAVARR/example-package',
+ $failingVersion,
+ [$dependency1, $dependency2]
+ );
+
+ $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock);
+ $command->execute('NAVARR/example-package', $failingVersion);
+ }
+
+ public function testDependenciesPassingConstraintsAreNotReturned(): void
+ {
+ $constraint = '<100';
+
+ $dependency1 = new DeclaredDependency(
+ package: static::TEST_PACKAGE,
+ constraint: $constraint,
+ reason: uniqid()
+ );
+
+ $dependency2 = new DeclaredDependency(
+ package: static::TEST_PACKAGE,
+ constraint: $constraint,
+ reason: uniqid()
+ );
+
+ $checkedVersion = '5';
+
+ $this->assertTrue(Semver::satisfies($checkedVersion, $constraint));
+
+ $aggregatorMock = $this->createAggregatorMock([$dependency1, $dependency2]);
+ $outputHandlerMock = $this->createOutputHandlerMock(static::TEST_PACKAGE, $checkedVersion);
+
+ $command = new WhyBlockCommand($aggregatorMock, $outputHandlerMock);
+ $command->execute(static::TEST_PACKAGE, $checkedVersion);
+ }
+}
diff --git a/tests/Controller/Composer/ComposerPluginTest.php b/tests/Controller/Composer/ComposerPluginTest.php
new file mode 100644
index 0000000..24d780b
--- /dev/null
+++ b/tests/Controller/Composer/ComposerPluginTest.php
@@ -0,0 +1,34 @@
+getCapabilities();
+ $this->assertArrayHasKey(CommandProvider::class, $capabilities);
+ $this->assertEquals(ComposerPlugin::class, $capabilities[CommandProvider::class]);
+ }
+
+ public function testContainsOnlyComposerCommands()
+ {
+ $plugin = new ComposerPlugin();
+ $commands = $plugin->getCommands();
+
+ $this->assertIsArray($commands);
+ $this->assertCount(1, $commands);
+ $command = end($commands);
+ $this->assertInstanceOf(ComposerCommand::class, $command);
+ }
+}
diff --git a/tests/Data/DeclaredDependencyTest.php b/tests/Data/DeclaredDependencyTest.php
new file mode 100644
index 0000000..cdf7816
--- /dev/null
+++ b/tests/Data/DeclaredDependencyTest.php
@@ -0,0 +1,64 @@
+assertEquals($file, $dependency->getFile());
+ $this->assertEquals($line, $dependency->getLine());
+ $this->assertEquals($reference, $dependency->getReference());
+ $this->assertEquals($package, $dependency->getPackage());
+ $this->assertEquals($version, $dependency->getConstraint());
+ $this->assertEquals($reason, $dependency->getReason());
+ $this->assertFalse($dependency->isRequired());
+ }
+
+ public function testDefaultValues()
+ {
+ $dependency = new DeclaredDependency();
+
+ $this->assertNull($dependency->getFile());
+ $this->assertNull($dependency->getLine());
+ $this->assertNull($dependency->getReference());
+ $this->assertNull($dependency->getPackage());
+ $this->assertNull($dependency->getConstraint());
+ $this->assertNull($dependency->getReason());
+ $this->assertTrue($dependency->isRequired());
+ }
+
+ public function testRequiredAttributeReturnsProvidedValue()
+ {
+ $dependency = new DeclaredDependency(required: true);
+ $this->assertTrue($dependency->isRequired());
+
+ $dependency = new DeclaredDependency(required: false);
+ $this->assertFalse($dependency->isRequired());
+ }
+}
diff --git a/tests/Data/ReferenceAdderTest.php b/tests/Data/ReferenceAdderTest.php
new file mode 100644
index 0000000..4b8442a
--- /dev/null
+++ b/tests/Data/ReferenceAdderTest.php
@@ -0,0 +1,61 @@
+assertEquals($package, $dependency->getPackage());
+ $this->assertEquals($constraint, $dependency->getConstraint());
+ $this->assertEquals($reason, $dependency->getReason());
+ $this->assertEquals($line, $dependency->getLine());
+ $this->assertNull($dependency->getFile());
+ $this->assertNull($dependency->getReference());
+
+ $referenceAdder = new ReferenceAdder();
+ $dependency2 = $referenceAdder->add(
+ $dependency,
+ $file
+ );
+
+ // Ensure original hasn't changed
+ $this->assertEquals($package, $dependency->getPackage());
+ $this->assertEquals($constraint, $dependency->getConstraint());
+ $this->assertEquals($reason, $dependency->getReason());
+ $this->assertEquals($line, $dependency->getLine());
+ $this->assertNull($dependency->getFile());
+ $this->assertNull($dependency->getReference());
+
+ // Check new
+ $this->assertEquals($package, $dependency2->getPackage());
+ $this->assertEquals($constraint, $dependency2->getConstraint());
+ $this->assertEquals($reason, $dependency2->getReason());
+ $this->assertEquals($line, $dependency2->getLine());
+ $this->assertEquals($file, $dependency2->getFile());
+ $this->assertEquals("{$file}:{$line}", $dependency2->getReference());
+ }
+}
diff --git a/tests/DeclaredDependencyAggregatorTest.php b/tests/DeclaredDependencyAggregatorTest.php
new file mode 100644
index 0000000..ef5e263
--- /dev/null
+++ b/tests/DeclaredDependencyAggregatorTest.php
@@ -0,0 +1,143 @@
+createMock(ScopeDeterminerInterface::class);
+ $scopeDeterminerMock->expects($this->once())
+ ->method('getFiles')
+ ->willReturn($files);
+
+ $parserMock = $this->createMock(ParserInterface::class);
+ $parserMock->expects($this->exactly(3))
+ ->method('parse')
+ ->withConsecutive(
+ // How can we update this so that we test each one was put through, but not necessarily consecutively?
+ [file_get_contents($files[0])],
+ [file_get_contents($files[1])],
+ [file_get_contents($files[2])]
+ );
+
+ $aggregator = new DeclaredDependencyAggregator(
+ $parserMock,
+ $scopeDeterminerMock,
+ new ReferenceAdder(),
+ new FailOnIssueHandler()
+ );
+
+ $aggregator->aggregate();
+ }
+
+ public function testIssueHandlerAlertedWhenFileCannotBeOpened(): void
+ {
+ $file = __DIR__ . '/_data/nonExistantFile';
+ $files = [$file];
+
+ $scopeDeterminerMock = $this->createMock(ScopeDeterminerInterface::class);
+ $scopeDeterminerMock->expects($this->once())
+ ->method('getFiles')
+ ->willReturn($files);
+
+ $parserMock = $this->createMock(ParserInterface::class);
+ $parserMock->method('parse')
+ ->willReturn([]);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage("Could not read from file '{$file}'");
+
+ $aggregator = new DeclaredDependencyAggregator(
+ $parserMock,
+ $scopeDeterminerMock,
+ new ReferenceAdder(),
+ new FailOnIssueHandler()
+ );
+
+ $aggregator->aggregate();
+ }
+
+ public function testParserNotCalledWhenFileCannotBeOpened(): void
+ {
+ $file = __DIR__ . '/_data/nonExistantFile';
+ $files = [$file];
+
+ $scopeDeterminerMock = $this->createMock(ScopeDeterminerInterface::class);
+ $scopeDeterminerMock->expects($this->once())
+ ->method('getFiles')
+ ->willReturn($files);
+
+ $parserMock = $this->createMock(ParserInterface::class);
+ $parserMock->expects($this->never())
+ ->method('parse');
+
+ $aggregator = new DeclaredDependencyAggregator(
+ $parserMock,
+ $scopeDeterminerMock,
+ new ReferenceAdder(),
+ null
+ );
+
+ $aggregator->aggregate();
+ }
+
+ public function testFileAndReferenceAreAddedToDeclaredDependency(): void
+ {
+ $file = __DIR__ . '/_data/attributeUsage.php';
+ $files = [$file];
+
+ $scopeDeterminerMock = $this->createMock(ScopeDeterminerInterface::class);
+ $scopeDeterminerMock->expects($this->once())
+ ->method('getFiles')
+ ->willReturn($files);
+
+ $line = '5';
+
+ $dependency = new DeclaredDependency(line: $line);
+ $this->assertNull($dependency->getFile());
+ $this->assertNull($dependency->getReference());
+
+ $parserMock = $this->createMock(ParserInterface::class);
+ $parserMock->expects($this->once())
+ ->method('parse')
+ ->willReturn([$dependency]);
+
+ $aggregator = new DeclaredDependencyAggregator(
+ $parserMock,
+ $scopeDeterminerMock,
+ new ReferenceAdder(),
+ null
+ );
+
+ $results = $aggregator->aggregate();
+ $this->assertCount(1, $results);
+
+ $newDependency = $results[0];
+
+ $this->assertEquals($line, $newDependency->getLine());
+ $this->assertEquals($file, $newDependency->getFile());
+ $this->assertEquals("{$file}:{$line}", $newDependency->getReference());
+ }
+}
diff --git a/tests/Factory/AbstractFactoryTest.php b/tests/Factory/AbstractFactoryTest.php
new file mode 100644
index 0000000..475d1ca
--- /dev/null
+++ b/tests/Factory/AbstractFactoryTest.php
@@ -0,0 +1,38 @@
+container = new Container();
+ }
+
+ public function testEachCreateReturnsNewInstance()
+ {
+ $factory = $this->create();
+
+ $instance1 = $factory->create();
+ $instance2 = $factory->create();
+
+ $this->assertFalse($instance1 === $instance2);
+ }
+
+ abstract public function testFactoryProducesExpectedType();
+}
diff --git a/tests/Factory/CollectingFactoryTest.php b/tests/Factory/CollectingFactoryTest.php
new file mode 100644
index 0000000..2de4874
--- /dev/null
+++ b/tests/Factory/CollectingFactoryTest.php
@@ -0,0 +1,25 @@
+container->get(CollectingFactory::class);
+ }
+
+ public function testFactoryProducesExpectedType()
+ {
+ $this->assertInstanceOf(Collecting::class, $this->create()->create());
+ }
+}
diff --git a/tests/Factory/FindingVisitorFactoryTest.php b/tests/Factory/FindingVisitorFactoryTest.php
new file mode 100644
index 0000000..2c79a12
--- /dev/null
+++ b/tests/Factory/FindingVisitorFactoryTest.php
@@ -0,0 +1,48 @@
+container->get(FindingVisitorFactory::class);
+ }
+
+ public function testEachCreateReturnsNewInstance()
+ {
+ $factory = $this->create();
+
+ $args = [
+ 'filterCallback' => static function () {
+ },
+ ];
+
+ $instance1 = $factory->create($args);
+ $instance2 = $factory->create($args);
+
+ $this->assertFalse($instance1 === $instance2);
+ }
+
+ public function testFactoryProducesExpectedType()
+ {
+ $this->assertInstanceOf(
+ FindingVisitor::class,
+ $this->create()->create(
+ [
+ 'filterCallback' => static function () {
+ },
+ ]
+ )
+ );
+ }
+}
diff --git a/tests/Factory/NodeTraverserFactoryTest.php b/tests/Factory/NodeTraverserFactoryTest.php
new file mode 100644
index 0000000..82d6289
--- /dev/null
+++ b/tests/Factory/NodeTraverserFactoryTest.php
@@ -0,0 +1,25 @@
+container->get(NodeTraverserFactory::class);
+ }
+
+ public function testFactoryProducesExpectedType()
+ {
+ $this->assertInstanceOf(NodeTraverser::class, $this->create()->create());
+ }
+}
diff --git a/tests/IssueHandler/FailOnIssueHandlerTest.php b/tests/IssueHandler/FailOnIssueHandlerTest.php
new file mode 100644
index 0000000..ac2fc14
--- /dev/null
+++ b/tests/IssueHandler/FailOnIssueHandlerTest.php
@@ -0,0 +1,31 @@
+expectException(RuntimeException::class);
+
+ $handler = new FailOnIssueHandler();
+ $handler->execute('');
+ }
+
+ public function testThrownExceptionContainsDescription()
+ {
+ $handler = new FailOnIssueHandler();
+
+ $randomMessage = uniqid();
+ $this->expectExceptionMessage($randomMessage);
+ $handler->execute($randomMessage);
+ }
+}
diff --git a/tests/IssueHandler/NotifyOnIssueHandlerTest.php b/tests/IssueHandler/NotifyOnIssueHandlerTest.php
new file mode 100644
index 0000000..b1db519
--- /dev/null
+++ b/tests/IssueHandler/NotifyOnIssueHandlerTest.php
@@ -0,0 +1,45 @@
+createMock(OutputInterface::class);
+ $outputMock->expects($this->once())
+ ->method('writeln')
+ ->with($this->equalTo("{$description}"));
+
+ $handler = new NotifyOnIssueHandler($outputMock);
+ $handler->execute($description);
+ }
+
+ public function testErrorOutputUsedIfExists()
+ {
+ $description = uniqid();
+
+ $errorOutputMock = $this->createMock(OutputInterface::class);
+ $errorOutputMock->expects($this->once())
+ ->method('writeln')
+ ->with($this->equalTo("{$description}"));
+
+ $outputMock = $this->createMock(ConsoleOutputInterface::class);
+ $outputMock->method('getErrorOutput')
+ ->willReturn($errorOutputMock);
+
+ $handler = new NotifyOnIssueHandler($outputMock);
+ $handler->execute($description);
+ }
+}
diff --git a/tests/Parser/AstParserTest.php b/tests/Parser/AstParserTest.php
new file mode 100644
index 0000000..827a7d5
--- /dev/null
+++ b/tests/Parser/AstParserTest.php
@@ -0,0 +1,168 @@
+make(AstParser::class, $args);
+ }
+
+ /**
+ * @return DeclaredDependency[]
+ */
+ private function getStandardResults(): array
+ {
+ $parser = $this->buildAstParser();
+ $contents = file_get_contents(__DIR__ . '/' . self::FILE_ATTRIBUTE_USAGE);
+ return $parser->parse($contents);
+ }
+
+ /**
+ * @return DeclaredDependency[]
+ */
+ public function testParserFindsAllAttributes(): array
+ {
+ $results = $this->getStandardResults();
+
+ $this->assertIsArray($results);
+ $this->assertCount(self::ATTRIBUTE_USAGE_ATTRIBUTE_COUNT, $results);
+ foreach ($results as $result) {
+ $this->assertInstanceOf(DeclaredDependency::class, $result);
+ }
+
+ return $results;
+ }
+
+ /**
+ * @depends testParserFindsAllAttributes
+ *
+ * @param DeclaredDependency[]
+ */
+ public function testParserFindsAllReasons(array $results)
+ {
+ /** A complete list of strings that should be found in the attributes gathered */
+ $searchReasons = [
+ 'Class Attribute',
+ 'Constant Attribute',
+ 'Property Attribute',
+ 'Method Attribute',
+ 'Method Parameter Attribute',
+ 'Function Attribute',
+ 'Function Parameter Attribute',
+ 'Mixed Parameter Order',
+ ];
+
+ foreach ($searchReasons as $searchReason) {
+ $this->assertContains(
+ $searchReason,
+ array_map(
+ static function (DeclaredDependency $result) {
+ return $result->getReason();
+ },
+ $results
+ )
+ );
+ }
+ }
+
+ /**
+ * @depends testParserFindsAllAttributes
+ *
+ * @param DeclaredDependency[]
+ */
+ public function testParserFindsAllVersions(array $results)
+ {
+ /** A complete list of version strings that should be found in the attributes gathered */
+ $searchVersions = [
+ '^1',
+ '^2',
+ '^3',
+ '^4',
+ '^5',
+ '^6',
+ '^7',
+ '^8',
+ '^9',
+ ];
+
+ foreach ($searchVersions as $searchVersion) {
+ $this->assertContains(
+ $searchVersion,
+ array_map(
+ static function (DeclaredDependency $result) {
+ return $result->getConstraint();
+ },
+ $results
+ )
+ );
+ }
+ }
+
+ public function testParserFailsOnInvalidFile()
+ {
+ $file = __DIR__ . '/' . self::FILE_INVALID;
+ $contents = file_get_contents($file);
+
+ $parser = $this->buildAstParser(['issueHandler' => new FailOnIssueHandler()]);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessageMatches("#^Could not parse contents#");
+
+ $parser->parse($contents);
+ }
+
+ public function testParserReturnsEmptyResultsOnInvalidFile()
+ {
+ $file = __DIR__ . '/' . self::FILE_INVALID;
+ $contents = file_get_contents($file);
+
+ $parser = $this->buildAstParser();
+ $result = $parser->parse($contents);
+
+ $this->assertIsArray($result);
+ $this->assertEmpty($result);
+ }
+
+ public function testParserGracefullyHandlesBadAttributeSyntax()
+ {
+ $file = __DIR__ . '/../_data/invalidAttributeUsage.php';
+ $contents = file_get_contents($file);
+
+ $parser = $this->buildAstParser();
+ $result = $parser->parse($contents);
+
+ $this->assertIsArray($result);
+ $this->assertEmpty($result);
+ }
+
+ public function testEmptyFileReturnsEmptyResults()
+ {
+ $file = __DIR__ . '/' . self::EMPTY_FILE;
+ $contents = file_get_contents($file);
+
+ $parser = $this->buildAstParser();
+ $result = $parser->parse($contents);
+
+ $this->assertIsArray($result);
+ $this->assertEmpty($result);
+ }
+}
diff --git a/tests/Parser/LegacyParserTest.php b/tests/Parser/LegacyParserTest.php
new file mode 100644
index 0000000..8d7399b
--- /dev/null
+++ b/tests/Parser/LegacyParserTest.php
@@ -0,0 +1,129 @@
+parse($contents);
+ }
+
+ public function testParserFindsAllAttributes(): array
+ {
+ $results = $this->getStandardResults();
+
+ $this->assertIsArray($results);
+ $this->assertCount(18, $results);
+ foreach ($results as $result) {
+ $this->assertInstanceOf(DeclaredDependency::class, $result);
+ }
+
+ return $results;
+ }
+
+ /**
+ * @param DeclaredDependency[] $results
+ * @depends testParserFindsAllAttributes
+ */
+ public function testParserTrimsReasons(array $results)
+ {
+ foreach ($results as $result) {
+ /** @var DeclaredDependency $result */
+
+ // No whitespace
+ $this->assertFalse(substr($result->getReason(), -1) == ' ');
+
+ // No end-comment symbols
+ $this->assertFalse(substr($result->getReason(), -2) == '*/');
+ }
+ }
+
+ /**
+ * @param DeclaredDependency[] $results
+ * @depends testParserFindsAllAttributes
+ */
+ public function testParserFindsAllRecordedReasons(array $results)
+ {
+ $reasons = [
+ 'composerDependency with version in big doc',
+ 'composerDependency without version in big doc',
+ 'dependency with version in big doc',
+ 'dependency without version in big doc',
+ 'dependency with version in small doc',
+ 'dependency without version in small doc',
+ 'composerDependency with version in small doc',
+ 'composerDependency without version in small doc',
+ 'dependency with version in slash doc after other content',
+ 'this is a test with the comment ending immediately after the reason',
+ ];
+
+ $resultReasons = array_map(
+ static function (DeclaredDependency $dependency) {
+ return $dependency->getReason();
+ },
+ $results
+ );
+
+ foreach ($reasons as $reason) {
+ $this->assertContains(
+ $reason,
+ $resultReasons
+ );
+ }
+ }
+
+ public function testEmptyFileReturnsEmptyResults()
+ {
+ $file = __DIR__ . '/' . self::EMPTY_FILE;
+
+ $parser = new LegacyParser();
+ $result = $parser->parse(file_get_contents($file));
+
+ $this->assertIsArray($result);
+ $this->assertEmpty($result);
+ }
+
+ public function testInvalidUnicodeContentGracefullyFails()
+ {
+ $content = "a\xff";
+
+ $parser = new LegacyParser();
+ $result = $parser->parse($content);
+
+ $this->assertIsArray($result);
+ $this->assertEmpty($result);
+ }
+
+ public function testIssueHandlerIsUtilized()
+ {
+ // I don't like doing this, but I can't reliably make this parser error :|
+ $handler = new FailOnIssueHandler();
+ $parser = new LegacyParser($handler);
+
+ $exceptionMessage = uniqid();
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage($exceptionMessage);
+
+ $reflectionClass = new \ReflectionClass($parser);
+ $method = $reflectionClass->getMethod('handleIssue');
+ $method->setAccessible(true);
+ $method->invoke($parser, $exceptionMessage);
+ }
+}
diff --git a/tests/Parser/ParserPoolTest.php b/tests/Parser/ParserPoolTest.php
new file mode 100644
index 0000000..dd33c99
--- /dev/null
+++ b/tests/Parser/ParserPoolTest.php
@@ -0,0 +1,60 @@
+expectException(TypeError::class);
+ $this->expectExceptionMessage('All parsers must implement Navarr\Depends\Parser\ParserInterface');
+
+ new ParserPool(['test']);
+ }
+
+ public function testAllParsersAreUsed()
+ {
+ $contents = uniqid();
+
+ $parser1Result = uniqid();
+ $parser1 = $this->createMock(ParserInterface::class);
+ $parser1->expects($this->once())
+ ->method('parse')
+ ->with($contents)
+ ->willReturn([$parser1Result]);
+
+ $parser2Result = uniqid();
+ $parser2 = $this->createMock(ParserInterface::class);
+ $parser2->expects($this->once())
+ ->method('parse')
+ ->with($contents)
+ ->willReturn([$parser2Result]);
+
+ $pool = new ParserPool([$parser1, $parser2]);
+ $result = $pool->parse($contents);
+
+ $this->assertIsArray($result);
+ $this->assertContains($parser1Result, $result);
+ $this->assertContains($parser2Result, $result);
+ }
+
+ public function testHandlesNoResults()
+ {
+ $pool = new ParserPool([]);
+ $result = $pool->parse('');
+
+ $this->assertIsArray($result);
+ $this->assertEmpty($result);
+ }
+}
diff --git a/tests/Proxy/StdOutWriterTest.php b/tests/Proxy/StdOutWriterTest.php
new file mode 100644
index 0000000..2b3bc8b
--- /dev/null
+++ b/tests/Proxy/StdOutWriterTest.php
@@ -0,0 +1,70 @@
+assertFalse($writer->canWrite());
+ }
+
+ public function testCanWriteReturnsTrueIfResourceCanBeWrittenTo()
+ {
+ $file = fopen('/tmp/example', 'w');
+ if ($file === false) {
+ $this->markTestSkipped('Could not open temporary file');
+ }
+
+ $writer = new StdOutWriter($file);
+ $this->assertTrue($writer->canWrite());
+
+ fclose($file);
+ }
+
+ public function testContentIsWritten()
+ {
+ $filename = '/tmp/' . uniqid();
+ $file = fopen($filename, 'w');
+ if ($file === false) {
+ $this->markTestSkipped('Could not open temporary file');
+ }
+
+ $contents = uniqid();
+
+ $writer = new StdOutWriter($file);
+ $writer->write($contents);
+ fclose($file);
+
+ $this->assertEquals($contents, file_get_contents($filename));
+ }
+
+ public function testCsvContentIsWritten()
+ {
+ $filename = '/tmp/' . uniqid();
+ $file = fopen($filename, 'w');
+ if ($file === false) {
+ $this->markTestSkipped('Could not open temporary file');
+ }
+
+ $contentA = uniqid();
+ $contentB = uniqid();
+ $contentC = uniqid();
+
+ $contentArray = [$contentA, $contentB, $contentC];
+ $expectedResult = "{$contentA},{$contentB},{$contentC}\n";
+
+ $writer = new StdOutWriter($file);
+ $writer->writeCsv($contentArray);
+ fclose($file);
+
+ $this->assertEquals($expectedResult, file_get_contents($filename));
+ }
+}
diff --git a/tests/ScopeDeterminer/PhpFileDeterminerTest.php b/tests/ScopeDeterminer/PhpFileDeterminerTest.php
new file mode 100644
index 0000000..59fd02e
--- /dev/null
+++ b/tests/ScopeDeterminer/PhpFileDeterminerTest.php
@@ -0,0 +1,71 @@
+createMock(MimeDeterminer::class);
+ $mockMimeDeterminer->expects($this->once())
+ ->method('getMimeType')
+ ->willReturn($pretendMimeType);
+
+ $determiner = new PhpFileDeterminer($mockMimeDeterminer);
+ $this->assertTrue($determiner->isPhp(uniqid() . '.html'));
+ }
+ }
+
+ public function testFileIsConsideredPhpBasedOnExtension()
+ {
+ $determiner = new PhpFileDeterminer(new MimeDeterminer());
+ $pretendFileNames = [
+ 'hellow.php',
+ 'im-full-of-html.phtml',
+ 'im-so-old-why-even-bother.php3',
+ 'im-not-much-better.php4',
+ 'i-guess-im-okay-but-who-uses-this.php5',
+ 'seriously-why-are-you-using-this-format.php7',
+ 'are-we-not-past-the-20th-century.php8',
+ ];
+
+ foreach ($pretendFileNames as $pretendFileName) {
+ $this->assertTrue(
+ $determiner->isPhp($pretendFileName)
+ );
+ }
+ }
+
+ public function testNonPhpFileIsNotConsideredPhp()
+ {
+ $file = uniqid() . '.html';
+
+ $mockMimeDeterminer = $this->createMock(MimeDeterminer::class);
+ $mockMimeDeterminer->expects($this->once())
+ ->method('getMimeType')
+ ->with($file)
+ ->willReturn('text/html');
+
+ $determiner = new PhpFileDeterminer($mockMimeDeterminer);
+ $this->assertFalse(
+ $determiner->isPhp($file)
+ );
+ }
+}
diff --git a/tests/_data/attributeUsage.php b/tests/_data/attributeUsage.php
new file mode 100644
index 0000000..0776778
--- /dev/null
+++ b/tests/_data/attributeUsage.php
@@ -0,0 +1,35 @@
+