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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/wiki
Submodule wiki updated from e25637 to 6421b6
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Relax workflow fallback Composer install so .dev-tools-actions bootstrap does not require composer.lock when provisioning DevTools runtime in shared GitHub Actions contexts (#342).
- Skip coverage report generation in reports command when no test surface is detectable (no default tests directory and no testable PHP source), while still generating docs and metrics.

## [1.25.4] - 2026-05-11

Expand Down
20 changes: 15 additions & 5 deletions src/Config/ComposerDependencyAnalyserConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

namespace FastForward\DevTools\Config;

use FastForward\DevTools\Environment\Environment;
use FastForward\DevTools\Environment\EnvironmentInterface;
use FastForward\DevTools\Path\DevToolsPathResolver;
use ShipMonk\ComposerDependencyAnalyser\Config\Configuration;
use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType;
Expand Down Expand Up @@ -86,14 +88,18 @@ final class ComposerDependencyAnalyserConfig
* Creates the default Composer Dependency Analyser configuration.
*
* @param callable|null $customize optional callback to customize the configuration
* @param EnvironmentInterface|null $environment optional environment reader used to resolve overrides
*
* @return Configuration the configured analyser configuration
*/
public static function configure(?callable $customize = null): Configuration
{
public static function configure(
?callable $customize = null,
?EnvironmentInterface $environment = null,
): Configuration {
$environment ??= new Environment();
$configuration = new Configuration();

if (! self::shouldShowShadowDependencies()) {
if (! self::shouldShowShadowDependencies($environment)) {
self::applyIgnoresShadowDependencies($configuration);
}

Expand Down Expand Up @@ -128,11 +134,15 @@ public static function applyIgnoresShadowDependencies(Configuration $configurati
/**
* Determines whether shadow dependency reports SHOULD remain visible.
*
* @param EnvironmentInterface|null $environment optional environment reader used to resolve overrides
*
* @return bool
*/
public static function shouldShowShadowDependencies(): bool
public static function shouldShowShadowDependencies(?EnvironmentInterface $environment = null): bool
{
return '1' === getenv(self::ENV_SHOW_SHADOW_DEPENDENCIES);
$environment ??= new Environment();

return '1' === $environment->get(self::ENV_SHOW_SHADOW_DEPENDENCIES);
}

/**
Expand Down
21 changes: 17 additions & 4 deletions src/Console/Command/ReportsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
use FastForward\DevTools\Console\Input\HasCacheOption;
use FastForward\DevTools\Console\Input\HasJsonOption;
use FastForward\DevTools\Path\DevToolsPathResolver;
use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface;
use FastForward\DevTools\Process\ProcessBuilderInterface;
use FastForward\DevTools\Process\ProcessQueueInterface;
use FastForward\DevTools\Path\ManagedWorkspace;
use Psr\Log\LogLevel;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
Expand All @@ -52,10 +54,12 @@ final class ReportsCommand extends Command
*
* @param ProcessBuilderInterface $processBuilder the builder instance used to construct execution processes
* @param ProcessQueueInterface $processQueue the execution queue mechanism for running sub-processes
* @param ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver the resolver for project capability detection
*/
public function __construct(
private readonly ProcessBuilderInterface $processBuilder,
private readonly ProcessQueueInterface $processQueue,
private readonly ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver,
) {
parent::__construct();
}
Expand Down Expand Up @@ -105,8 +109,8 @@ protected function configure(): void
/**
* Executes the generation logic for diverse reports.
*
* The method MUST run the underlying `docs` and `tests` commands. It SHALL process
* and generate the frontpage output file successfully.
* The method MUST run the underlying `docs` command and, when applicable,
* the `tests` command for coverage generation.
*
* @param InputInterface $input the structured inputs holding specific arguments
* @param OutputInterface $output the designated output interface
Expand Down Expand Up @@ -186,7 +190,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$coverageBuilder = $coverageBuilder->withArgument('--pretty-json');
}

$coverage = $coverageBuilder->build([DevToolsPathResolver::getBinaryPath(), 'tests']);
$projectCapabilities = $this->projectCapabilitiesResolver->resolve();
if ($projectCapabilities->canRunTests()) {
$coverage = $coverageBuilder->build([DevToolsPathResolver::getBinaryPath(), 'tests']);
$this->processQueue->add(process: $coverage, label: 'Generating Coverage Report');
Comment thread
coisa marked this conversation as resolved.
} else {
$this->log(
'Skipping coverage report because no tests directory or PHP source files were detected.',
$input,
logLevel: LogLevel::WARNING,
);
}

if ($progress) {
$metricsBuilder = $metricsBuilder->withArgument('--progress');
Expand All @@ -203,7 +217,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$metrics = $metricsBuilder->build([DevToolsPathResolver::getBinaryPath(), 'metrics']);

$this->processQueue->add(process: $docs, detached: true, label: 'Generating API Docs Report');
$this->processQueue->add(process: $coverage, label: 'Generating Coverage Report');
$this->processQueue->add(process: $metrics, label: 'Generating Metrics Report');

$result = $this->processQueue->run($processOutput);
Expand Down
42 changes: 31 additions & 11 deletions src/Console/Command/TestsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use FastForward\DevTools\Console\Input\HasCacheOption;
use FastForward\DevTools\Console\Input\HasJsonOption;
use FastForward\DevTools\Composer\Json\ComposerJsonInterface;
use FastForward\DevTools\Environment\EnvironmentInterface;
use FastForward\DevTools\Filesystem\FilesystemInterface;
use FastForward\DevTools\Path\DevToolsPathResolver;
use FastForward\DevTools\PhpUnit\Bootstrap\BootstrapShimGenerator;
Expand Down Expand Up @@ -60,9 +61,9 @@ final class TestsCommand extends Command
use HasJsonOption;
use LogsCommandResults;

private const string AGENT_ENVIRONMENT_VARIABLE = 'AI_AGENT';
public const string AGENT_ENVIRONMENT_VARIABLE = 'AI_AGENT';

private const string AGENT_ENVIRONMENT_VALUE = 'fast-forward/dev-tools';
public const string AGENT_ENVIRONMENT_VALUE = 'fast-forward/dev-tools';

private const string PROCESS_LABEL = 'Running PHPUnit Tests';

Expand All @@ -71,6 +72,8 @@ final class TestsCommand extends Command
*/
public const string CONFIG = 'phpunit.xml';

public const string ENV_MINIMUM_COVERAGE = 'FAST_FORWARD_MIN_COVERAGE';

/**
* @param CoverageSummaryLoaderInterface $coverageSummaryLoader the loader used for `coverage-php` summaries
* @param ComposerJsonInterface $composer the composer.json reader for autoload information
Expand All @@ -80,6 +83,7 @@ final class TestsCommand extends Command
* @param ProcessBuilderInterface $processBuilder the builder used to assemble the PHPUnit process
* @param ProcessQueueInterface $processQueue the queue used to execute PHPUnit
* @param ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver the project capability resolver
* @param EnvironmentInterface $environment the environment resolver for CLI-scoped flags
*/
public function __construct(
private readonly CoverageSummaryLoaderInterface $coverageSummaryLoader,
Expand All @@ -90,6 +94,7 @@ public function __construct(
private readonly ProcessBuilderInterface $processBuilder,
private readonly ProcessQueueInterface $processQueue,
private readonly ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver,
private readonly EnvironmentInterface $environment,
) {
parent::__construct();
}
Expand Down Expand Up @@ -193,7 +198,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int

if (! $projectCapabilities->canRunTests()) {
return $this->success(
'Skipping PHPUnit tests because no tests directory or PHP source files were detected.',
'Skipping PHPUnit tests because no Composer-autoloaded PHP source files were detected.',
$input,
[
'output' => $processOutput,
Expand Down Expand Up @@ -249,14 +254,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$result = $this->processQueue->run($processOutput);
$processResultContext = $this->resolveProcessResultContext($processOutput, $result, $structuredOutput);

if (self::SUCCESS !== $result || null === $minimumCoverage || null === $coverageReportPath) {
if (self::SUCCESS === $result) {
return $this->success('PHPUnit tests completed successfully.', $input, $processResultContext);
}

if (self::SUCCESS !== $result) {
return $this->failure('PHPUnit tests failed.', $input, $processResultContext);
}

if (null === $minimumCoverage || null === $coverageReportPath) {
return $this->success('PHPUnit tests completed successfully.', $input, $processResultContext);
}

[$validationResult, $message, $coverageContext] = $this->validateMinimumCoverage(
$coverageReportPath,
$minimumCoverage,
Expand Down Expand Up @@ -314,10 +319,9 @@ private function resolveProcessResultContext(
private function forceAgentReporter(Process $process): void
{
$env = $process->getEnv();
$parentAgentEnvironment = $this->environment->get(self::AGENT_ENVIRONMENT_VARIABLE);

if (\array_key_exists(self::AGENT_ENVIRONMENT_VARIABLE, $env) || false !== getenv(
self::AGENT_ENVIRONMENT_VARIABLE
)) {
if (\array_key_exists(self::AGENT_ENVIRONMENT_VARIABLE, $env) || null !== $parentAgentEnvironment) {
return;
}

Expand Down Expand Up @@ -515,9 +519,15 @@ private function resolveMinimumCoverage(InputInterface $input): ?float
$minimumCoverage = $input->getOption('min-coverage');

if (null === $minimumCoverage) {
$minimumCoverage = $this->resolveMinimumCoverageFromEnvironment();
}

if (false === $minimumCoverage || '' === trim((string) $minimumCoverage)) {
return null;
}

$minimumCoverage = trim((string) $minimumCoverage);

if (! is_numeric($minimumCoverage)) {
throw new InvalidArgumentException('The --min-coverage option MUST be a numeric percentage.');
}
Expand All @@ -531,6 +541,16 @@ private function resolveMinimumCoverage(InputInterface $input): ?float
return $minimumCoverage;
}

/**
* Resolves minimum-coverage value from injected environment abstraction.
*
* @return string|false|null the configured coverage threshold or a falsey fallback
*/
private function resolveMinimumCoverageFromEnvironment(): ?string
{
return $this->environment->get(self::ENV_MINIMUM_COVERAGE);
}

/**
* @param InputInterface $input the raw parameter definitions
* @param ProcessBuilderInterface $processBuilder the process builder to extend with coverage arguments
Expand Down
4 changes: 3 additions & 1 deletion src/Console/DevTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
*/
final class DevTools extends Application
{
public const string ENV_AUTO_UPDATE = 'FAST_FORWARD_AUTO_UPDATE';

private const string LOGO = <<<'LOGO'
____ _____ _
| _ \ _____ _|_ _|__ ___ | |___
Expand Down Expand Up @@ -201,7 +203,7 @@ private function configureWorkspaceDirectory(InputInterface $input): void
*/
private function runAutoUpdateWhenRequested(InputInterface $input, OutputInterface $output): void
{
$autoUpdateMode = $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '');
$autoUpdateMode = $this->environment->get(self::ENV_AUTO_UPDATE, '');

if (! $input->hasParameterOption('--auto-update', true) && ! $this->isTruthyAutoUpdateMode($autoUpdateMode)) {
return;
Expand Down
57 changes: 40 additions & 17 deletions src/Path/ManagedWorkspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

namespace FastForward\DevTools\Path;

use FastForward\DevTools\Environment\Environment;
use FastForward\DevTools\Environment\EnvironmentInterface;
use Symfony\Component\Filesystem\Path;

/**
Expand Down Expand Up @@ -80,10 +82,14 @@ final class ManagedWorkspace
*
* @param string $path the optional relative segment to append under the managed output root
* @param string $baseDir the optional repository root used to resolve the managed workspace path
* @param EnvironmentInterface|null $environment explicit environment reader used to resolve FAST_FORWARD_WORKSPACE_DIR
*/
public static function getOutputDirectory(string $path = '', string $baseDir = ''): string
{
$baseDir = self::getWorkspaceRoot($baseDir);
public static function getOutputDirectory(
string $path = '',
string $baseDir = '',
?EnvironmentInterface $environment = null,
): string {
$baseDir = self::getWorkspaceRoot(baseDir: $baseDir, environment: $environment);

return '' === $path
? $baseDir
Expand All @@ -99,10 +105,14 @@ public static function getOutputDirectory(string $path = '', string $baseDir = '
*
* @param string $path the optional relative cache segment to append under the managed cache root
* @param string $baseDir the optional repository root used to resolve the managed cache path
* @param EnvironmentInterface|null $environment explicit environment reader used to resolve FAST_FORWARD_WORKSPACE_DIR
*/
public static function getCacheDirectory(string $path = '', string $baseDir = ''): string
{
$baseDir = self::getOutputDirectory(self::CACHE_ROOT, $baseDir);
public static function getCacheDirectory(
string $path = '',
string $baseDir = '',
?EnvironmentInterface $environment = null,
): string {
$baseDir = self::getOutputDirectory(path: self::CACHE_ROOT, baseDir: $baseDir, environment: $environment);

return '' === $path
? $baseDir
Expand All @@ -117,12 +127,17 @@ public static function getCacheDirectory(string $path = '', string $baseDir = ''
* under that base directory while absolute workspaces are used as-is.
*
* @param string $baseDir the optional repository root used to resolve a relative workspace
* @param EnvironmentInterface|null $environment explicit environment reader used to resolve FAST_FORWARD_WORKSPACE_DIR
*/
public static function getWorkspaceRoot(string $baseDir = ''): string
{
$workspaceRoot = getenv(self::ENV_WORKSPACE_DIR);
public static function getWorkspaceRoot(
string $baseDir = '',
?EnvironmentInterface $environment = null,
): string {
$environment ??= new Environment();

if (false === $workspaceRoot || '' === $workspaceRoot) {
$workspaceRoot = $environment->get(self::ENV_WORKSPACE_DIR);

if (null === $workspaceRoot || '' === $workspaceRoot) {
$workspaceRoot = self::WORKSPACE_ROOT;
}

Expand All @@ -138,17 +153,25 @@ public static function getWorkspaceRoot(string $baseDir = ''): string
* should skip generated artifacts during source scans.
*
* @param string $baseDir the optional repository root used to relativize absolute workspace paths
* @param EnvironmentInterface|null $environment explicit environment reader used for tests
*/
public static function getProjectRelativeWorkspaceRoot(string $baseDir = ''): ?string
{
$workspaceRoot = self::getWorkspaceRoot();

if (! Path::isAbsolute($workspaceRoot)) {
return $workspaceRoot;
public static function getProjectRelativeWorkspaceRoot(
string $baseDir = '',
?EnvironmentInterface $environment = null,
): ?string {
$environment ??= new Environment();
$workspaceRoot = $environment->get(self::ENV_WORKSPACE_DIR);

if (null === $workspaceRoot || '' === $workspaceRoot) {
$workspaceRoot = self::WORKSPACE_ROOT;
}

if ('' === $baseDir) {
return null;
return Path::isAbsolute($workspaceRoot) ? null : $workspaceRoot;
}

if (! Path::isAbsolute($workspaceRoot)) {
return $workspaceRoot;
}

$baseDir = Path::canonicalize($baseDir);
Expand Down
Loading