Skip to content
Draft
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,13 @@ Before committing, `make csfix && make psalm && make test` must all pass (CI gat

## Pipeline (how a `check` run flows)

`bin/phparkitect` → `PhpArkitectApplication` registers the commands in `src/CLI/Command/` (`Check`, `GenerateBaseline`, `Init`, `DebugExpression`). Options shared between analysis-running commands (`--config`, `--target-php-version`, `--autoload`, `--ignore-baseline-linenumbers`) are defined once in `CommonOptions`, and the shared console plumbing (process limits, progress, heading, timing) in `CommandRuntime` — both composed by each command.
`bin/phparkitect` → `PhpArkitectApplication` registers the commands in `src/CLI/Command/` (`Check`, `GenerateBaseline`, `PruneBaseline`, `Init`, `DebugExpression`). Options shared between analysis-running commands (`--config`, `--target-php-version`, `--autoload`, `--ignore-baseline-linenumbers`) are defined once in `CommonOptions`, and the shared console plumbing (process limits, progress, heading, timing) in `CommandRuntime` — both composed by each command.

`Check::execute` (`src/CLI/Command/Check.php`) parses the CLI options into a `CheckOptions` value object and delegates to `CheckHandler` (`src/CLI/CheckHandler.php`), the application service that runs the actual flow:
1. `ConfigBuilder` loads the user's `phparkitect.php`, which receives a `Config` and registers `ClassSet`s + `Rule`s.
2. `Runner` (`src/CLI/Runner.php`) iterates every `SplFileInfo` in each `ClassSet`, parses it via `FileParser`, and gets back `ClassDescription`s.
3. Each `Rule` is evaluated against each `ClassDescription`, accumulating `Violations`.
4. `Baseline` (`src/CLI/Baseline.php`) filters out known/ignored violations.
4. `Baseline` (`src/CLI/Baseline.php`, a pure domain object persisted via `BaselineFileRepository`) filters out known/ignored violations.
5. A `Printer` (`text` / `json` / `gitlab`, `src/CLI/Printer/`) renders the result; exit code is non-zero if violations remain.

## Architecture — the two halves
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,15 @@ phparkitect generate-baseline

This creates `phparkitect-baseline.json`. Subsequent `check` runs pick it up automatically. Use a custom file name with `phparkitect generate-baseline my-baseline.json`, point `check` to it with `--use-baseline=my-baseline.json`, or skip it entirely with `--skip-baseline`.

The `generate-baseline` command accepts the same `--config`, `--target-php-version`, `--autoload` and `--ignore-baseline-linenumbers` options as `check`.
When violations get fixed over time, prune the baseline instead of regenerating it:

```
phparkitect prune-baseline
```

Pruning only removes entries that no longer match a current violation — it never adds anything. Regenerating snapshots the entire current state, so it would silently legitimize any new violation introduced since the baseline was created; pruning cannot, which makes it safe to run routinely (even automated). Since matching ignores line numbers and the kept entries are saved with their current ones, pruning also refreshes a baseline whose line numbers went stale after refactorings. `check` prints a hint when it detects baseline entries that look fixed.

Both `generate-baseline` and `prune-baseline` accept an optional custom file name as argument and the same `--config`, `--target-php-version` and `--autoload` options as `check`; `generate-baseline` also accepts `--ignore-baseline-linenumbers` to write the baseline without line numbers. `prune-baseline` needs no such flag: matching already ignores line numbers, and a baseline stored without line numbers keeps its format when pruned.

> **Note**: baseline generation was previously a `check` option (`check --generate-baseline`); it is now a dedicated command, and the old option fails with a pointer to the new one.

Expand Down
84 changes: 38 additions & 46 deletions src/CLI/Baseline.php
Original file line number Diff line number Diff line change
@@ -1,30 +1,41 @@
<?php

declare(strict_types=1);

namespace Arkitect\CLI;

use Arkitect\Json;
use Arkitect\Rules\Violations;

/**
* The set of known violations to be ignored by a check run.
*
* This is a pure domain object: loading and saving baselines from disk
* lives in BaselineFileRepository.
*/
class Baseline
{
public const DEFAULT_FILENAME = 'phparkitect-baseline.json';

private Violations $violations;

private string $filename;

private int $staleViolationsCount = 0;

private function __construct(Violations $violations, string $filename)
private function __construct(Violations $violations)
{
$this->violations = $violations;
$this->filename = $filename;
}

public function getFilename(): string
public static function fromViolations(Violations $violations): self
{
return new self($violations);
}

public static function empty(): self
{
return new self(new Violations());
}

public function getViolations(): Violations
{
return $this->filename;
return $this->violations;
}

public function applyTo(Violations $violations, bool $ignoreBaselineLinenumbers): void
Expand All @@ -45,55 +56,36 @@ public function getStaleViolationsCount(): int
}

/**
* @psalm-suppress RiskyTruthyFalsyComparison
* Shrink-only update: returns a baseline containing only the entries that
* still match a current violation — nothing is ever added. Matching
* ignores line numbers and the current violations are the ones kept, so
* pruning also refreshes line numbers gone stale after refactorings.
*/
public static function resolveFilePath(?string $filePath, string $defaultFilePath): ?string
public function prune(Violations $currentViolations): self
{
if (!$filePath && file_exists($defaultFilePath)) {
$filePath = $defaultFilePath;
}
$prunedViolations = $currentViolations->intersection($this->violations);

return $filePath ?: null;
}

public static function empty(): self
{
return new self(new Violations(), '');
}

public static function create(bool $skipBaseline, ?string $baselineFilePath): self
{
if ($skipBaseline || null === $baselineFilePath) {
return self::empty();
// a baseline stored without line numbers keeps its format
if (!$this->hasLineNumbers()) {
$prunedViolations = $prunedViolations->withoutLineNumbers();
}

return self::loadFromFile($baselineFilePath);
return new self($prunedViolations);
}

public static function loadFromFile(string $filename): self
public function withoutLineNumbers(): self
{
if (!file_exists($filename)) {
throw new \RuntimeException("Baseline file '$filename' not found.");
}

$contents = file_get_contents($filename);

if (false === $contents) {
throw new \RuntimeException("Baseline file '$filename' could not be read.");
}

return new self(
Violations::fromJson($contents),
$filename
);
return new self($this->violations->withoutLineNumbers());
}

public static function save(string $filename, Violations $violations, bool $ignoreLineNumbers = false): void
private function hasLineNumbers(): bool
{
if ($ignoreLineNumbers) {
$violations = $violations->withoutLineNumbers();
foreach ($this->violations as $violation) {
if (null !== $violation->getLine()) {
return true;
}
}

file_put_contents($filename, Json::encode($violations));
return false;
}
}
41 changes: 41 additions & 0 deletions src/CLI/BaselineFileRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Arkitect\CLI;

use Arkitect\Json;
use Arkitect\Rules\Violations;

/**
* Loads and saves Baselines as JSON files. All the filesystem concerns of
* the baseline live here, so Baseline itself stays a pure domain object.
*/
final class BaselineFileRepository
{
public const DEFAULT_FILENAME = 'phparkitect-baseline.json';

public function load(string $filename): Baseline
{
if (!file_exists($filename)) {
throw new \RuntimeException("Baseline file '$filename' not found.");
}

return Baseline::fromViolations(Violations::fromJson((string) file_get_contents($filename)));
}

public function save(Baseline $baseline, string $filename): void
{
file_put_contents($filename, Json::encode($baseline->getViolations()));
}

/**
* The baseline at $filename, or null when the file is absent — for
* callers (like check) where the baseline is optional and a missing
* one just means "nothing to ignore".
*/
public function loadIfPresent(string $filename): ?Baseline
{
return file_exists($filename) ? $this->load($filename) : null;
}
}
21 changes: 14 additions & 7 deletions src/CLI/CheckHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
*/
final class CheckHandler
{
public function __construct(private Runner $runner)
{
public function __construct(
private Runner $runner,
private BaselineFileRepository $baselineRepository,
) {
}

public function check(
Expand All @@ -30,14 +32,19 @@ public function check(
->autoloadFilePath($options->getAutoloadFilePath())
->stopOnFailure($options->isStopOnFailure())
->targetPhpVersion(TargetPhpVersion::create($options->getTargetPhpVersion()))
->baselineFilePath($options->getBaselineFilePath())
->ignoreBaselineLinenumbers($options->isIgnoreBaselineLinenumbers())
->skipBaseline($options->isSkipBaseline())
->format($options->getFormat());

$baseline = Baseline::create($config->isSkipBaseline(), $config->getBaselineFilePath());
// the baseline is optional for check: an absent file just means there
// is nothing to ignore, so we fall back to an empty baseline
$baselineFilePath = $options->getBaselineFilePath();
$baseline = null === $baselineFilePath ? null : $this->baselineRepository->loadIfPresent($baselineFilePath);

$baseline->getFilename() && $output->writeln("Baseline file '{$baseline->getFilename()}' found");
if (null === $baseline) {
$baseline = Baseline::empty();
} else {
$output->writeln("Baseline file '$baselineFilePath' found");
}

$output->writeln("Config file '{$options->getConfigFilePath()}' found\n");

Expand Down Expand Up @@ -74,7 +81,7 @@ private function printStaleBaselineViolations(Baseline $baseline, OutputInterfac
$verb = 1 === $staleViolationsCount ? 'looks' : 'look';
$pronoun = 1 === $staleViolationsCount ? 'it' : 'them';
$noun = 1 === $staleViolationsCount ? 'violation' : 'violations';
$output->writeln("💡 {$staleViolationsCount} {$noun} in the baseline {$verb} fixed — regenerate the baseline to remove {$pronoun}");
$output->writeln("💡 {$staleViolationsCount} {$noun} in the baseline {$verb} fixed — run `phparkitect prune-baseline` to remove {$pronoun}");
}
}
}
6 changes: 0 additions & 6 deletions src/CLI/CheckOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ public function __construct(
private ?string $targetPhpVersion,
private bool $stopOnFailure,
private ?string $baselineFilePath,
private bool $skipBaseline,
private bool $ignoreBaselineLinenumbers,
private string $format,
private ?string $autoloadFilePath,
Expand All @@ -42,11 +41,6 @@ public function getBaselineFilePath(): ?string
return $this->baselineFilePath;
}

public function isSkipBaseline(): bool
{
return $this->skipBaseline;
}

public function isIgnoreBaselineLinenumbers(): bool
{
return $this->ignoreBaselineLinenumbers;
Expand Down
27 changes: 21 additions & 6 deletions src/CLI/Command/Check.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace Arkitect\CLI\Command;

use Arkitect\CLI\Baseline;
use Arkitect\CLI\BaselineFileRepository;
use Arkitect\CLI\CheckHandler;
use Arkitect\CLI\CheckOptions;
use Arkitect\CLI\Runner;
Expand Down Expand Up @@ -37,7 +37,7 @@ public function __construct(?CheckHandler $handler = null)

parent::__construct('check');

$this->handler = $handler ?? new CheckHandler(new Runner());
$this->handler = $handler ?? new CheckHandler(new Runner(), new BaselineFileRepository());
$this->runtime = new CommandRuntime();
}

Expand Down Expand Up @@ -80,6 +80,7 @@ protected function configure(): void
);

$this->commonOptions->addTo($this);
$this->commonOptions->addIgnoreBaselineLinenumbers($this);
}

protected function isRunningAsPhar(): bool
Expand Down Expand Up @@ -138,17 +139,31 @@ protected function execute(InputInterface $input, OutputInterface $output): int

protected function parseOptions(InputInterface $input): CheckOptions
{
$useBaseline = (string) $input->getOption(self::USE_BASELINE_PARAM);

return new CheckOptions(
configFilePath: $this->commonOptions->configFilePath($input),
targetPhpVersion: $this->commonOptions->targetPhpVersion($input),
stopOnFailure: (bool) $input->getOption(self::STOP_ON_FAILURE_PARAM),
baselineFilePath: Baseline::resolveFilePath($useBaseline, Baseline::DEFAULT_FILENAME),
skipBaseline: (bool) $input->getOption(self::SKIP_BASELINE_PARAM),
baselineFilePath: $this->resolveBaselineFilePath($input),
ignoreBaselineLinenumbers: $this->commonOptions->isIgnoreBaselineLinenumbers($input),
format: (string) $input->getOption(self::FORMAT_PARAM),
autoloadFilePath: $this->commonOptions->autoloadFilePath($input),
);
}

/**
* The baseline file check should use: an explicit --use-baseline, or the
* default phparkitect-baseline.json. Null when --skip-baseline opts out.
* Whether that file exists is decided at load time — the baseline is
* optional for check, so an absent one just means nothing to ignore.
*/
private function resolveBaselineFilePath(InputInterface $input): ?string
{
if ((bool) $input->getOption(self::SKIP_BASELINE_PARAM)) {
return null;
}

$useBaseline = (string) $input->getOption(self::USE_BASELINE_PARAM);

return '' !== $useBaseline ? $useBaseline : BaselineFileRepository::DEFAULT_FILENAME;
}
}
21 changes: 15 additions & 6 deletions src/CLI/Command/CommonOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,24 @@ public function addTo(Command $command): void
'a',
InputOption::VALUE_REQUIRED,
'Specify an autoload file to use',
)
->addOption(
self::IGNORE_BASELINE_LINENUMBERS_PARAM,
'i',
InputOption::VALUE_NONE,
'Ignore line numbers when checking or generating the baseline'
);
}

/**
* Not part of addTo() because not every analysis command takes it:
* prune-baseline always matches ignoring line numbers and preserves
* the baseline's stored format instead.
*/
public function addIgnoreBaselineLinenumbers(Command $command): void
{
$command->addOption(
self::IGNORE_BASELINE_LINENUMBERS_PARAM,
'i',
InputOption::VALUE_NONE,
'Ignore line numbers when checking or generating the baseline'
);
}

public function configFilePath(InputInterface $input): string
{
return (string) $input->getOption(self::CONFIG_PARAM);
Expand Down
Loading