Repository navigation
feat(#19): don't score idiomatic construction and delegation as tramp data - #20
Merged
Merged
Conversation
Records the two decisions the issue left open: parent:: delegation scores neither a hop nor a class (same object, not a collaborator), and exclusion by terminal kind becomes an opt-in --exclude-terminal knob rather than a default-off rule for 'stored'. The latter is rejected as a default because the README's own headline finding is a stored terminal — hop count, not terminal kind, is what separates the issue's noisy example from a real tunnel.
Six tasks, fixture-first, one commit each. Task 1 is a prerequisite refactor: Finding::$hops doubles as the terminal-node index in five reporters, so the score cannot change until that coupling is cut.
…p count Finding::$hops doubled as the hop score and as the index of the terminal node in $chain; five reporters read it as an index. Issue #19 makes the score stop counting parent:: delegation, which would silently break all five. Ask the terminal kind whether a terminal node exists instead — the rule its docblock already stated in prose.
A subclass forwarding to parent::__construct() is inheritance wiring, not a value tramping across collaborators: subclass and base are the same object. Every custom exception in a codebase produced a finding. The node stays in the rendered chain, it just stops scoring. self::/static:: are unaffected — same class, ordinary hop. Amends frozen rule 4.
… gaps SummaryReporter never applies thresholds, so a chain scoring 0 hops (pure parent:: delegation) still listed every custom exception in --format summary. Filter it out before the four blocks are built, including the totals footer and the empty-findings message. Also adds the fourth worked-case test (collaborator hop before parent delegation) and corrects two docblocks (Finding, GithubReporter::anchorIndex) left stale by the hops-scoring change.
The node stays in the chain and stops scoring, so the reader needs to see why the hop count is lower than the chain is long. Pretty's annotation styling moves into annotation() — a hop can now carry both (parent) and *YOURS*, which the leading-paren sniff in hopLine() would have styled wrongly.
A team that tightens thresholds may not want chains ending in a constructor or value object — the parameter has arrived at its home. That is project policy, so it is one config line rather than a #[TrampIgnore] on every factory. A denylist, defaulting to empty: adding a TerminalKind later cannot silently change anyone's results, which an allowlist default could not promise. Filtering runs after suppression so an excluded chain's #[TrampIgnore] still fires and is not reported stale.
Pins the task's central ordering decision with a regression test (a suppressed, stored-terminated chain run with --exclude-terminal stored --fail-on-stale must not go stale), removes a premature early return in TerminalKindFilter::filter() that Infection would flag as an escaped mutant, pins the CLI/config union semantics for excludeTerminals, fixes three now-contradictory passages in the design spec, drops an inert inline @PARAM duplicate on Options::$excludeTerminals, and widens Options::testWithFormatChangesOnlyTheFormat to seed all 22 constructor arguments so it actually catches a dropped withFormat() argument.
expectExceptionMessage() does substring containment, so a mutant that transposes the concatenated parts of the "unknown terminal kind" message still contains the asserted substring and escapes. Catch the exception and assertSame() on the full message instead, closing the 4-mutant gap on TerminalKindFilter::fromNames().
Semantic decision, flagged per CLAUDE.md's frozen-semantics rule: distinctClasses() skipped every viaParent node on the justification that "a delegating subclass is represented by the base it delegates into". That only holds when the base is in the chain. When it is outside the analyzed index -- ExternalTarget / TruncatedResolution, i.e. the `extends \RuntimeException` shape issue #19 is squarely about -- there is no successor node and the class count silently lost one: a chain rendering two classes reported "across 1 class", and --min-classes 2 dropped a finding that was reported before this branch. The value genuinely crossed a collaborator boundary there. So the branch chooses: skip a viaParent node's class only when the node it delegates into is actually present in the chain; count it when the delegator ends the chain. The hop score is unaffected -- a parent:: node still never scores a hop -- and since minClasses only suppresses, this can never add findings beyond restoring the ones the branch had dropped. New fixture parent-delegation-external-base pins it, run at --min-classes 2 so it fails on the pre-fix arithmetic rather than merely snapshotting a number. plan.md's frozen rule 4 and the design spec now state the condition the discount depends on. Also in this review wave: - GithubReporter: inline the nonTerminalHops() rename-wrapper left over from the hops-as-index refactor. - helpText(): --exclude-terminal in the Status footer. - ApplicationTest: --exclude-terminal with an unknown kind exits 2 with the kind-name message even when the folder is unparseable, pinning that validation runs before buildIndex(); and a baselined chain whose terminal kind becomes excluded does go stale, the counterpart to the suppression-ordering test the spec's section 3 claims. - README: shell-quoting note on '&-terminated'. - Recount the fixture findings the "stored stays a finding" rationale cites: 7 of 21, in both plan.md and the design spec.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #19
Teaches phptramp to tell idiomatic object construction and delegation apart from
tramp data. Two independent changes, plus one prerequisite refactor.
1.
parent::delegation is not a hop and not a classA subclass constructor forwarding to
parent::__construct()is inheritancewiring on the same object, not a value crossing a collaborator boundary — so
a node that forwards through any
parent::call now counts toward neither thehop score nor the class count. It stays visible in the rendered chain, marked
(parent)in text/pretty and"viaParent": truein JSON, so nothing is hidden;it just stops accumulating score.
self::/static::are unaffected — sameclass, ordinary hop.
A three-deep exception hierarchy now scores 0 hops across 1 class and is
reported by no formatter. This is what removed "one finding per custom
exception" from the report.
One conditional worth knowing: the delegator's class is skipped only when the
base it delegates into is actually present in the chain. When that base is
outside the analyzed index —
class ThinException extends \RuntimeException—nothing downstream represents it, so its class is counted. Otherwise the header
would say "1 class" above a chain visibly listing two, and
--min-classes 2would silently drop findings it used to report.
This amends frozen rule 4 in
docs/plan.md.2.
--exclude-terminal <kind>/excludeTerminalsAn opt-in denylist filtering findings by
TerminalKind, repeatable on the CLIand available as a config key. Default
[]— nothing excluded, so no existingoutput moves. It unions with the config list rather than replacing it (config is
project policy; a one-off CI run adds to it), which is deliberately the opposite
of the path flags. Validation lives in exactly one place and runs before the
index build, so a typo fails fast with exit 2.
What this deliberately does NOT do
Issue #19's own noisy example is not silenced by default.
begin→persistedLog→new RunLogis astoredterminal with noparent::anywhere,so this branch does not change its output unless you opt into
--exclude-terminal stored.That is an argued rejection, not an oversight. The issue proposed that
storedterminals should not be findings by default. But the README's own headline
finding —
$configforwarded through a controller and two services intonew Mailer($config)— is astoredterminal, as are 7 of the 21 findingsacross
tests/fixtures/. The issue's noisy example is 2 hops across 2 classes;the README's legitimate one is 3 hops across 4 classes. Both are
stored, soterminal kind is not what separates them — hop count is. Excluding
storedbydefault would delete the tool's canonical finding. The rationale is recorded in
docs/plan.md's appendix so it does not get re-proposed.Prerequisite refactor
Finding::$hopswas doing double duty as the hop score and the index of theterminal node inside
Finding::$chain; five reporters read it as an index.Changing what the score counts would have silently broken all five, so the first
commit severs that:
TerminalKind::keepsTerminalNode()answers the structuralquestion, and
hopsbecomes purely a score.Verification
composer checkgreen: PHPCS, PHPStan level max, PHPMD, 526 tests / 1737 assertions.composer infection:diffMSI ~94.9% against theminMsi 80gate.parent-delegation-hierarchy,parent-delegation-mixed,parent-delegation-external-base,exclude-terminal-stored); everypre-existing fixture and reporter expectation is unchanged, which is the
evidence that the default behaviour did not move.