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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Every setting can be passed as a CLI option or set via the corresponding `Config
| `--autoload` | `-a` | `autoloadFilePath()` | Autoload file to load before running. Required for all Phar runs. |
| `--use-baseline` | `-b` | `baselineFilePath()` | Baseline file path for ignoring known violations. |
| `--skip-baseline` | `-k` | `skipBaseline()` | Skips the default baseline even if present. |
| `--ignore-baseline-linenumbers` | `-i` | `ignoreBaselineLinenumbers()` | Matches baseline violations without checking line numbers. |
| `--ignore-baseline-linenumbers` | `-i` | `ignoreBaselineLinenumbers()` | **Deprecated**: has no effect, baseline matching already tolerates moved violations. |
| `--config` | `-c` | — | Configuration file to load (default: `phparkitect.php`). |
| `--verbose` | `-v` | — | Prints every parsed file instead of the progress bar. |
| — | — | `skipParsingCustomAnnotations()` | Disables custom DocBlock annotation parsing (enabled by default). |
Expand All @@ -165,15 +165,17 @@ When violations get fixed over time, prune the baseline instead of regenerating
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.
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). Pruning also refreshes a baseline whose line numbers went stale after refactorings, since the kept entries are saved with their current ones. `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.
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`.

> **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.

By default the baseline also checks line numbers — a change before the offending line shifts the number and the check fails. Use `--ignore-baseline-linenumbers` to match violations regardless of line number.
#### How baseline entries are matched

> **Warning**: when ignoring line numbers, PHPArkitect cannot detect if the same rule is violated additional times in the same file.
You don't have to choose: a violation is identified by its class and by what it reports, never by where it sits in the file. Entries are matched first by exact position, and whatever is left over is matched within the same class and rule, in file order. A change above the offending line therefore doesn't reopen a known violation, while two violations of the same rule in the same class stay distinct — and when a new one appears among them, it's the new one that gets reported.

`--ignore-baseline-linenumbers` / `ignoreBaselineLinenumbers()` used to select this behaviour and is now **deprecated**: it has no effect and will be removed in the next major version. Existing baselines keep working and need no regeneration, whether or not they store line numbers.

### Output format

Expand Down
27 changes: 25 additions & 2 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ PHPArkitect, ordered from the most recent version to the oldest.

## 1.3.0

### `--ignore-baseline-linenumbers` is deprecated

Baseline matching no longer depends on line numbers: a violation is identified
by its class and by what it reports, so an edit above the offending line does
not reopen a known violation, and two violations of the same rule in the same
class stay distinct. The option that used to select this behaviour has no
effect and prints a deprecation notice, both from the CLI and from
`phparkitect.php`:

```diff
- phparkitect check --ignore-baseline-linenumbers
+ phparkitect check

- $config->ignoreBaselineLinenumbers(true);
+ $config;
```

It will be removed in the next major version.

`generate-baseline` now always writes line numbers — the option no longer
strips them. **Existing baselines keep working and need no regeneration**,
whether or not they store line numbers, and `prune-baseline` still preserves
the format it finds.

### `check --generate-baseline` is now the `generate-baseline` command

Generating a baseline was an action disguised as a `check` option: it ran a
Expand All @@ -24,8 +48,7 @@ a dedicated command:

The optional filename is now an argument (still defaulting to
`phparkitect-baseline.json`), and the command accepts the same `--config`,
`--target-php-version`, `--autoload` and `--ignore-baseline-linenumbers`
options as before. The check-only options that never affected generation
`--target-php-version` and `--autoload` options as before. The check-only options that never affected generation
(`--stop-on-failure`, `--format`, `--use-baseline`, `--skip-baseline`) are no
longer accepted.

Expand Down
12 changes: 6 additions & 6 deletions src/CLI/Baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,22 @@ public function getViolations(): Violations
* given set untouched: what is left to report is in the returned result,
* together with the number of baseline entries nothing matched.
*/
public function applyTo(Violations $violations, bool $ignoreBaselineLinenumbers): BaselineResult
public function applyTo(Violations $violations): BaselineResult
{
$match = $violations->matchAgainst($this->violations, $ignoreBaselineLinenumbers);
$match = $violations->matchAgainst($this->violations);

return new BaselineResult($match->new(), $match->stale()->count());
}

/**
* 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.
* still match a current violation — nothing is ever added. The current
* violations are the ones kept, so pruning also refreshes line numbers
* gone stale after refactorings.
*/
public function prune(Violations $currentViolations): self
{
$prunedViolations = $currentViolations->matchAgainst($this->violations, true)->known();
$prunedViolations = $currentViolations->matchAgainst($this->violations)->known();

// a baseline stored without line numbers keeps its format
if (!$this->hasLineNumbers()) {
Expand Down
5 changes: 4 additions & 1 deletion src/CLI/CheckHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ public function check(
->autoloadFilePath($options->getAutoloadFilePath())
->stopOnFailure($options->isStopOnFailure())
->targetPhpVersion(TargetPhpVersion::create($options->getTargetPhpVersion()))
->ignoreBaselineLinenumbers($options->isIgnoreBaselineLinenumbers())
->format($options->getFormat());

if ($options->isIgnoreBaselineLinenumbers() || $config->isIgnoreBaselineLinenumbers()) {
$output->writeln(DeprecationNotice::IGNORE_BASELINE_LINENUMBERS);
}

$baselineFilePath = $options->getBaselineFilePath();

if (null === $baselineFilePath) {
Expand Down
9 changes: 5 additions & 4 deletions src/CLI/Command/CommonOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,18 @@ public function addTo(Command $command): void
}

/**
* 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.
* Not part of addTo() because prune-baseline never took it.
*
* The option is deprecated and has no effect: it is still registered so
* that the commands accept it and can warn who is passing it.
*/
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'
'Deprecated: has no effect, baseline matching already tolerates moved violations'
);
}

Expand Down
5 changes: 5 additions & 0 deletions src/CLI/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ public function getBaselineFilePath(): ?string
return $this->baselineFilePath;
}

/**
* @deprecated baseline matching no longer depends on line numbers; this
* option has no effect and will be removed in the next major
* version. The value is kept only to warn the user about it.
*/
public function ignoreBaselineLinenumbers(bool $ignoreBaselineLinenumbers): self
{
$this->ignoreBaselineLinenumbers = $ignoreBaselineLinenumbers;
Expand Down
13 changes: 13 additions & 0 deletions src/CLI/DeprecationNotice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Arkitect\CLI;

/**
* The messages printed when a deprecated option is used.
*/
class DeprecationNotice
{
public const IGNORE_BASELINE_LINENUMBERS = '⚠️ `--ignore-baseline-linenumbers` / `ignoreBaselineLinenumbers()` is deprecated and has no effect: baseline matching now tolerates violations moved by edits elsewhere in the file. It will be removed in the next major version.';
}
2 changes: 1 addition & 1 deletion src/CLI/GenerateBaselineHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public function generateBaseline(GenerateBaselineOptions $options, Progress $pro
$baseline = Baseline::fromViolations($result->getViolations());

if ($options->isIgnoreBaselineLinenumbers()) {
$baseline = $baseline->withoutLineNumbers();
$output->writeln(DeprecationNotice::IGNORE_BASELINE_LINENUMBERS);
}

$this->baselineRepository->save($baseline, $options->getBaselineFilePath());
Expand Down
2 changes: 1 addition & 1 deletion src/CLI/Runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public function run(Config $config, Baseline $baseline, Progress $progress): Ana
{
[$violations, $parsingErrors] = $this->doRun($config, $progress);

$baselineResult = $baseline->applyTo($violations, $config->isIgnoreBaselineLinenumbers());
$baselineResult = $baseline->applyTo($violations);

return new AnalysisResult(
$baselineResult->getRemainingViolations(),
Expand Down
81 changes: 55 additions & 26 deletions src/Rules/Violations.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,37 +82,29 @@ public function toArray(): array
* $baseline, one to one: what matched, what is new and what the baseline
* still claims but nothing matches anymore.
*
* A violation is identified by its class and by the problem it reports;
* $ignoreLineNumbers decides whether where it sits in the file is part of
* that identity too.
* Pairing happens in two passes: first the violations still sitting where
* the baseline recorded them, then, among what is left, the ones matching
* by class and reported problem alone — an edit above a violation moves it
* without making it a new one. The line number is therefore never part of
* the identity of a violation, only a hint on which entry of a group to
* pair with, so there is nothing for the user to choose here.
*
* When a group of identical violations both moved and grew, which of them
* is reported as new is a guess; how many are is not.
*/
public function matchAgainst(self $baseline, bool $ignoreLineNumbers): ViolationsMatch
public function matchAgainst(self $baseline): ViolationsMatch
{
$key = $ignoreLineNumbers ? [__CLASS__, 'violationKey'] : [__CLASS__, 'positionKey'];
$unpairedByKey = self::indexBy($baseline->violations, $key);

$known = [];
$new = [];
$paired = [];

foreach ($this->violations as $violation) {
$violationKey = $key($violation);

if ([] === ($unpairedByKey[$violationKey] ?? [])) {
$new[] = $violation;

continue;
}
[$stillThere, $moved, $paired] = self::pairWith($this->violations, $baseline->violations, [__CLASS__, 'positionKey']);

// the bucket was just checked to be non-empty, so array_pop() returns an index
/** @psalm-suppress PossiblyNullArrayOffset */
$paired[array_pop($unpairedByKey[$violationKey])] = true;
$known[] = $violation;
}
$unpairedEntries = array_diff_key($baseline->violations, $paired);

$stale = array_diff_key($baseline->violations, $paired);
[$movedAndKnown, $new, $pairedMoved] = self::pairWith($moved, $unpairedEntries, [__CLASS__, 'violationKey']);

return new ViolationsMatch(self::fromArray($known), self::fromArray($new), self::fromArray($stale));
return new ViolationsMatch(
self::fromArray(array_intersect_key($this->violations, $stillThere + $movedAndKnown)),
self::fromArray($new),
self::fromArray(array_diff_key($unpairedEntries, $pairedMoved))
);
}

public function withoutLineNumbers(): self
Expand All @@ -135,6 +127,43 @@ public function jsonSerialize(): array
return get_object_vars($this);
}

/**
* Pairs each violation with an entry carrying the same key, in the order
* both appear in their file, and reports what paired with what.
*
* @param array<int, Violation> $violations
* @param array<int, Violation> $entries
* @param callable(Violation):string $key
*
* @return array{array<int, true>, array<int, Violation>, array<int, true>} the paired violations, the unpaired ones, the paired entries
*/
private static function pairWith(array $violations, array $entries, callable $key): array
{
$entriesByKey = self::indexBy($entries, $key);

$matched = [];
$unpaired = [];
$paired = [];
$pairedPerKey = [];

foreach ($violations as $idx => $violation) {
$violationKey = $key($violation);
$alreadyPaired = $pairedPerKey[$violationKey] ?? 0;

if (!isset($entriesByKey[$violationKey][$alreadyPaired])) {
$unpaired[$idx] = $violation;

continue;
}

$pairedPerKey[$violationKey] = $alreadyPaired + 1;
$paired[$entriesByKey[$violationKey][$alreadyPaired]] = true;
$matched[$idx] = true;
}

return [$matched, $unpaired, $paired];
}

/**
* Groups the given violations by the key the callback derives from each
* of them, so that only the ones that can possibly match are compared.
Expand Down
18 changes: 12 additions & 6 deletions tests/E2E/Cli/CheckCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -179,17 +179,23 @@ public function test_dependencies_should_not_leak_between_files(): void
self::assertCommandWasSuccessful($cmdTester);
}

public function test_baseline_line_numbers_can_be_ignored(): void
public function test_baseline_matches_violations_whose_line_number_moved(): void
{
$configFilePath = __DIR__.'/../_fixtures/configIgnoreBaselineLineNumbers.php';

// No errors when ignoring baseline line numbers
$cmdTester = $this->runCheck($configFilePath, null, __DIR__.'/../_fixtures/line_numbers/baseline.json', false, true);
$cmdTester = $this->runCheck($configFilePath, null, __DIR__.'/../_fixtures/line_numbers/baseline.json');

self::assertCommandWasSuccessful($cmdTester);
}

// Errors when not ignoring baseline line numbers
$cmdTester = $this->runCheck($configFilePath, null, __DIR__.'/../_fixtures/line_numbers/baseline.json');
self::assertCommandExitedWithError($cmdTester);
public function test_deprecated_ignore_baseline_linenumbers_warns_and_changes_nothing(): void
{
$configFilePath = __DIR__.'/../_fixtures/configIgnoreBaselineLineNumbers.php';

$cmdTester = $this->runCheck($configFilePath, null, __DIR__.'/../_fixtures/line_numbers/baseline.json', false, true);

self::assertCommandWasSuccessful($cmdTester);
self::assertStringContainsString('is deprecated and has no effect', $cmdTester->getErrorOutput());
}

public function test_baseline_reports_stale_violations(): void
Expand Down
8 changes: 4 additions & 4 deletions tests/E2E/Cli/GenerateBaselineCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,20 +55,20 @@ public function test_creates_the_baseline_with_a_custom_filename(): void
self::assertFileDoesNotExist($this->defaultBaselineFilename);
}

public function test_can_ignore_line_numbers(): void
public function test_deprecated_ignore_line_numbers_warns_and_still_writes_them(): void
{
$cmdTester = $this->runGenerateBaseline(
__DIR__.'/../_fixtures/configMvcForYieldBug.php',
__DIR__.'/../_fixtures/configIgnoreBaselineLineNumbers.php',
$this->customBaselineFilename,
true
);

self::assertEquals(self::SUCCESS_CODE, $cmdTester->getStatusCode());
self::assertStringContainsString('is deprecated and has no effect', $cmdTester->getDisplay());

$baseline = json_decode((string) file_get_contents($this->customBaselineFilename), true);

self::assertCount(1, $baseline['violations']);
self::assertNull($baseline['violations'][0]['line']);
self::assertNotNull($baseline['violations'][0]['line']);
}

public function test_fails_gracefully_when_the_config_file_does_not_exist(): void
Expand Down
24 changes: 16 additions & 8 deletions tests/E2E/Cli/PruneBaselineCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,22 +70,20 @@ public function test_pruning_an_up_to_date_baseline_changes_nothing(): void
}

/**
* Pruning has no --ignore-baseline-linenumbers flag: it always matches
* ignoring line numbers and infers the format to save from the baseline
* itself, so a line-numberless workflow has nothing to remember.
* Baselines written by older versions with --ignore-baseline-linenumbers
* store no line number: pruning keeps that format instead of silently
* upgrading the file.
*/
public function test_pruning_preserves_a_baseline_generated_without_line_numbers(): void
public function test_pruning_preserves_a_baseline_stored_without_line_numbers(): void
{
// this fixture violates a dependency rule, so its violations carry a
// line number: without -i the baseline would store one
$configFilePath = __DIR__.'/../_fixtures/configIgnoreBaselineLineNumbers.php';

$this->runCommand([
'generate-baseline',
'--config' => $configFilePath,
'filename' => $this->customBaselineFilename,
'--ignore-baseline-linenumbers' => true,
]);
$this->stripLineNumbersFrom($this->customBaselineFilename);

$cmdTester = $this->runCommand(['prune-baseline', '--config' => $configFilePath, 'filename' => $this->customBaselineFilename]);

Expand All @@ -104,7 +102,6 @@ public function test_pruning_preserves_a_baseline_generated_without_line_numbers
'check',
'--config' => $configFilePath,
'--use-baseline' => $this->customBaselineFilename,
'--ignore-baseline-linenumbers' => true,
]);

self::assertEquals(self::SUCCESS_CODE, $cmdTester->getStatusCode());
Expand All @@ -131,4 +128,15 @@ protected function runCommand(array $input): ApplicationTester

return $appTester;
}

private function stripLineNumbersFrom(string $baselineFilename): void
{
$baseline = json_decode((string) file_get_contents($baselineFilename), true);

foreach ($baseline['violations'] as $idx => $violation) {
$baseline['violations'][$idx]['line'] = null;
}

file_put_contents($baselineFilename, json_encode($baseline));
}
}
Loading