Skip to content

feat(#19): don't score idiomatic construction and delegation as tramp data - #20

Merged
larspohlmann merged 11 commits into
developfrom
feature/19-construction-delegation
Aug 14, 2026
Merged

larspohlmann merged 11 commits into
developfrom
feature/19-construction-delegation

Conversation

@larspohlmann

Copy link
Copy Markdown
Owner

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 class

A subclass constructor forwarding to parent::__construct() is inheritance
wiring on the same object, not a value crossing a collaborator boundary — so
a node that forwards through any parent:: call now counts toward neither the
hop score nor the class count. It stays visible in the rendered chain, marked
(parent) in text/pretty and "viaParent": true in JSON, so nothing is hidden;
it just stops accumulating score. self::/static:: are unaffected — same
class, 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 2
would silently drop findings it used to report.

This amends frozen rule 4 in docs/plan.md.

2. --exclude-terminal <kind> / excludeTerminals

An opt-in denylist filtering findings by TerminalKind, repeatable on the CLI
and available as a config key. Default [] — nothing excluded, so no existing
output 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 RunLog is a stored terminal with no parent:: 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 stored
terminals should not be findings by default. But the README's own headline
finding — $config forwarded through a controller and two services into
new Mailer($config) — is a stored terminal, as are 7 of the 21 findings
across 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, so
terminal kind is not what separates them — hop count is. Excluding stored by
default 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::$hops was doing double duty as the hop score and the index of the
terminal 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 structural
question, and hops becomes purely a score.

Verification

  • composer check green: PHPCS, PHPStan level max, PHPMD, 526 tests / 1737 assertions.
  • composer infection:diff MSI ~94.9% against the minMsi 80 gate.
  • 4 new fixtures (parent-delegation-hierarchy, parent-delegation-mixed,
    parent-delegation-external-base, exclude-terminal-stored); every
    pre-existing fixture and reporter expectation is unchanged, which is the
    evidence that the default behaviour did not move.

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.
@larspohlmann
larspohlmann merged commit 412f322 into develop Aug 14, 2026
5 checks passed
@larspohlmann
larspohlmann deleted the feature/19-construction-delegation branch August 14, 2026 09:26
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.

Don't flag idiomatic construction/delegation as tramp data (parent-constructor forwarding, stored terminals)

1 participant