Skip to content

Add prune-baseline command with shrink-only semantics#657

Draft
fain182 wants to merge 8 commits into
mainfrom
claude/prune-baseline-649
Draft

Add prune-baseline command with shrink-only semantics#657
fain182 wants to merge 8 commits into
mainfrom
claude/prune-baseline-649

Conversation

@fain182

@fain182 fain182 commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

New Feature

Q A
New Feature yes
RFC no
BC Break no
Issue Close #649

Summary

Completes the *-baseline command family started with #655/#656. Regenerating the baseline snapshots the entire current state, so it silently legitimizes any violation introduced since the baseline was created — the classic baseline trap. prune-baseline is the safe counterpart:

phparkitect prune-baseline [filename]
  • Shrink-only by design: runs the analysis and keeps only the intersection between the existing baseline and the current violations — the baseline can never grow, so unlike regenerating it cannot hide new violations, which makes it safe to run routinely (even automated).
  • Refreshes stale line numbers: matching ignores line numbers (via the new Violations::intersection(), the complementary operation of Violations::remove(), reusing the same stable-key matching) and the kept entries are the current violations, so pruning also repairs a baseline whose line numbers drifted after refactorings.
  • The Add message to report when baseline violations look fixed #638 hint now points here, as planned in the issue: 💡 1 violation in the baseline looks fixed — run phparkitect prune-baseline to remove it (it used to suggest a risky full regeneration).
  • No --ignore-baseline-linenumbers flag: on pruning it would not control matching (which always ignores line numbers) but only the saved format — a footgun for the line-numberless workflow. Instead, Baseline::prune() preserves the stored format: a baseline generated with -i (no line numbers) stays line-numberless after pruning, with nothing to remember. The -i option definition moved out of CommonOptions::addTo() into a separate method composed only by check and generate-baseline, where it actually means something.

Pruning needs the full load → transform → save lifecycle, so this PR also splits the two responsibilities Baseline used to mix:

  • Baseline is now a pure domain object: applyTo(), stale counting, the new prune() and withoutLineNumbers().
  • BaselineFileRepository owns every filesystem concern: load(), save(), resolveFilePath() and the default filename. All three handlers (check, generate-baseline, prune-baseline) take it via constructor injection.

The command mirrors its siblings, composing CommonOptions and CommandRuntime.

Covered by unit tests (BaselineTest — including format preservation, BaselineFileRepositoryTest, PruneBaselineHandlerTest, Violations::intersection cases) and E2E tests (PruneBaselineCommandTest); the full generate → hint → prune cycle was also exercised against the real CLI. README and CLAUDE.md updated.

🤖 Generated with Claude Code

https://claude.ai/code/session_015K1TKiwtwkW7twBCTz758P

Closes #649.

Regenerating the baseline snapshots the entire current state, so it
silently legitimizes any violation introduced since the baseline was
created. prune-baseline is the safe counterpart:

    phparkitect prune-baseline [filename]

It runs the analysis and keeps only the intersection between the
existing baseline and the current violations — the baseline can only
shrink. Matching ignores line numbers and the kept entries are the
current violations, so pruning also refreshes line numbers gone stale
after refactorings. Unlike generation, pruning is safe to automate.

The check hint introduced in #638 now points to the new command instead
of suggesting a (risky) full regeneration.

This change also splits the two responsibilities Baseline used to mix,
since pruning needs the full load -> transform -> save lifecycle:

- Baseline is now a pure domain object: applyTo(), stale counting, the
  new prune() (built on the new Violations::intersection(), the
  complementary operation of Violations::remove(), reusing the same
  stable-key matching) and withoutLineNumbers().
- BaselineFileRepository owns every filesystem concern: load(), save(),
  resolveFilePath() and the default filename. Check, generate-baseline
  and prune-baseline all take it via constructor injection.

The command follows the same structure as its siblings, composing
CommonOptions and CommandRuntime. --ignore-baseline-linenumbers on
prune-baseline strips line numbers from the saved file (matching
already ignores them by design).

Covered by unit tests (Baseline, BaselineFileRepository,
PruneBaselineHandler, Violations::intersection) and E2E tests; README
and CLAUDE.md updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015K1TKiwtwkW7twBCTz758P
@codecov

codecov Bot commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.47328% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.84%. Comparing base (88a3138) to head (6e2adf1).

Files with missing lines Patch % Lines
src/CLI/Command/PruneBaseline.php 95.00% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##               main     #657      +/-   ##
============================================
- Coverage     98.28%   97.84%   -0.45%     
- Complexity      737      762      +25     
============================================
  Files            94       98       +4     
  Lines          2100     2181      +81     
============================================
+ Hits           2064     2134      +70     
- Misses           36       47      +11     
Files with missing lines Coverage Δ
src/CLI/Baseline.php 100.00% <100.00%> (+3.33%) ⬆️
src/CLI/BaselineFileRepository.php 100.00% <100.00%> (ø)
src/CLI/CheckHandler.php 100.00% <100.00%> (ø)
src/CLI/CheckOptions.php 100.00% <ø> (ø)
src/CLI/Command/Check.php 100.00% <100.00%> (ø)
src/CLI/Command/CommonOptions.php 100.00% <100.00%> (ø)
src/CLI/Command/GenerateBaseline.php 95.23% <100.00%> (ø)
src/CLI/GenerateBaselineHandler.php 100.00% <100.00%> (ø)
src/CLI/PhpArkitectApplication.php 100.00% <100.00%> (ø)
src/CLI/PruneBaselineHandler.php 100.00% <100.00%> (ø)
... and 3 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

claude added 7 commits July 17, 2026 16:52
…stored format instead

On prune-baseline the flag did not control matching (pruning always
matches ignoring line numbers — that is how it refreshes stale ones):
it only stripped line numbers from the saved file. That made it a
footgun for the line-numberless workflow (generate-baseline -i +
check -i): forgetting it on a prune would silently convert the baseline
back to a line-numbered file, reintroducing exactly the drift the user
opted out of.

Instead of an output-format flag, Baseline::prune() now preserves the
stored format: a baseline whose entries have no line numbers stays
line-numberless after pruning. No flag to remember, and pruning can
never make the baseline worse.

CommonOptions::addTo() loses the -i option, which moves to a separate
addIgnoreBaselineLinenumbers() composed only by check and
generate-baseline, where the option actually means something.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015K1TKiwtwkW7twBCTz758P
The stderr redirect exists in check because stdout is reserved for the
violations payload (json/gitlab formats piped to files). The baseline
commands have no stdout payload — their console output is all
diagnostics — so redirecting it to stderr was cargo cult that made
'phparkitect generate-baseline > log.txt' capture nothing. They now
write to the output they receive, like any Symfony command; progress
bars still go to stderr via Symfony's own ConsoleOutputInterface
handling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015K1TKiwtwkW7twBCTz758P
The old name hid both decisions the method took: 'resolve' did not say
it probes the filesystem, and the nullable-in/nullable-out signature
mixed two different concerns — the explicit-path-wins precedence
(command policy) and the default-file auto-detection (persistence
knowledge).

The precedence now reads inline in Check::parseOptions, and the
repository keeps only its own piece with a name that says what it does:
findDefaultFilePath() returns the default baseline file or null when
none exists. The RiskyTruthyFalsyComparison suppression dies with the
falsy-parameter check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015K1TKiwtwkW7twBCTz758P
findDefaultFilePath() returned ?string but carried a single bit: the
non-null value was always DEFAULT_FILENAME, never a different path. So
it was a yes/no question dressed as a finder.

It becomes hasDefaultBaseline(): bool, a pure persistence predicate, and
Check maps 'default exists' to the DEFAULT_FILENAME path — keeping the
path-selection policy in the command layer, consistent with where the
explicit-wins precedence already lives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015K1TKiwtwkW7twBCTz758P
…alue

'Which baseline to subtract' was represented twice — as ?string
baselineFilePath and bool skipBaseline — and resolved in two layers:
parseOptions handled --use-baseline and the default file, while
CheckHandler handled skip (skip ? null : path). skip is just another
route to 'empty baseline', i.e. baselineFilePath = null, so the two
fields encoded the same decision.

The decision now lives in one place, Check::resolveBaselineFilePath(),
which reads top-to-bottom as the precedence it implements: --skip-baseline
forces empty; an explicit --use-baseline wins over the auto-detected
default; a missing default just means empty. Its output is the single
?string baselineFilePath, where null means 'empty baseline'.

CheckOptions loses the redundant skipBaseline field, and CheckHandler
drops the baselineFilePath/skipBaseline pass-through it wrote into Config
and immediately read back (nothing else reads them from Config) — it now
loads-or-empties straight from the resolved value. No behavior change:
the CLI still resolves exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015K1TKiwtwkW7twBCTz758P
baselineFilePath is a field — a value — but resolveBaselineFilePath was
stuffing filesystem logic into it: 'the default path, but only if the
file exists'. That is why hasDefaultBaseline() had to probe disk at parse
time; a path field secretly carried an existence fact.

Now the field holds a plain path: --use-baseline, or the default
phparkitect-baseline.json (null only for --skip-baseline). Existence is
decided where loading happens: CheckHandler loads the baseline if the
file is there and falls back to an empty one otherwise — the baseline is
optional for check, so an absent file just means nothing to ignore.

hasDefaultBaseline() is replaced by a general BaselineFileRepository::
exists(), used by the handler for both the fallback and the 'found'
message.

Behavior change: check --use-baseline=missing.json no longer errors, it
runs with an empty baseline — consistent with a missing default, and it
fails in the safe direction (all violations shown). prune-baseline keeps
its strict load: there a missing baseline is still an error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015K1TKiwtwkW7twBCTz758P
exists() was a thin file_exists() wrapper with a single caller, which
then did check-then-load: exists() followed by load(). Fold both into
one repository capability — loadIfPresent() returns the baseline, or null
when the file is absent — so CheckHandler makes one call, there is no
TOCTOU gap, and the method name states the optional-load intent.

load() stays strict for prune-baseline; loadIfPresent() is the optional
variant for check, where a missing baseline just means nothing to ignore.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015K1TKiwtwkW7twBCTz758P
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a prune-baseline command to remove fixed violations from the baseline

2 participants