Report a private or protected member reached from outside its scope - #380
Conversation
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Thanks for the benchmark job — it made me go and measure properly, and I think First, a small thing: the step also failed at the end trying to post its The alert does not reproduce here. Same two commits, criterion, my machine:
CI reported 1.46× and 1.32×. It also measured the baseline at 0.052 ms and I do not want to wave the remaining 3 % away, so: completion never runs any of There is one real cost, though, and I should have put it in the description.
If the 1.30 threshold on microsecond benchmarks is something you want held, I am 🤖 Generated with Claude Code |
PHP resolves a member access to a declaration first and enforces that declaration's visibility second, so reading a private property from outside its class is a fatal error rather than a missing member. Neither was reported. The check runs inside the unknown-member walk, which has already resolved the subject expression to a class. It looks the member up in the raw declarations — the class as parsed, then its traits, then its ancestors — rather than in the merged class, because the inheritance merge drops a parent's private members and would make them look absent instead of unreachable. Walking the raw chain is also what supplies the declaring class, which is the scope `private` and `protected` are measured against: a member declared on a shared parent stays reachable from every branch below it while one declared on a sibling does not. Properties, methods, class constants, and static properties are checked. Nothing is reported unless a declaration is positively found and positively out of reach, so an unresolvable ancestor, a virtual member, or a class the loader cannot produce end in silence. A class declaring `__get`, `__set`, `__call`, or `__callStatic` anywhere in its hierarchy answers for members the caller cannot see directly and is left alone, as is a trait body, whose host class is unknown, and a `@see` tag, which documents a member rather than reading one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
05e63a8 to
52874b4
Compare
Note
I am not a Rust developer. This came up while using PHPantom on my own PHP
CMS, and I fixed it with AI assistance — written with Claude Opus 5, reviewed
by GPT-5.6 Codex, which sent the design back three times before this shape
survived. I would be glad if you read through the proposed changes, even if
you end up not using the code.
Adds
invalid_member_access(Error): aprivateorprotectedproperty,method, class constant, or static property reached from a scope that cannot see
it. This and unreachable code were the only two checks I found missing against
Intelephense in day-to-day use.
The problem
member_exists()matched on name alone.Visibilityis already onMethodInfo,PropertyInfo, andConstantInfo, and completion already filterson it, but no diagnostic read it.
A parent's private member was worse than unreported. The merge drops it, since
PHP does not inherit private members, so this was reported as
unknown_member:The change
Runs inside the unknown-member walk, which has already resolved the subject to a
class. No second resolution pass, no second walk over the symbol map.
Visibility is read from the assembled class. Trait adaptations are therefore
not re-derived —
use T { run as private; }, aliases, andinsteadofarealready applied by
inheritance/traits.rs.The declaring class is computed (
declaring_class()), because the assembledclass does not carry it:
merge_traits_into()receives the host FQN and dropsit.
inheritance/mod.rsmerges an ancestor's traits into the descendant, so aprivate member on a merged class may have been declared in a trait a parent
uses. Consequences:
The walk runs only for a non-public member reached from inside some class.
Public members and everything reached from outside every class are settled by
the merged lookup the surrounding pass already performs.
Suppressed: a magic handler on the class or inherited from a parent; a trait
body (host class unknown);
@seetags; a union with one permitting branch; anancestor the loader cannot produce; a provenance walk that finds nothing.
$this,self, andstaticname the class the code is bound to, whichdiffers from the enclosing class for a
Macroablecallback or anyClosure::bind, so those three add the receiver as a scope.self_and_static_in_macro_closure_resolve_to_macro_targetfails without this.parent::does not count — it names the class to look the member up in, not thescope doing the looking.
What the review rejected
declaration. Ignores trait adaptations:
run as privateinvisible,insteadofcan pick the losing declaration. False positive.Disproved by
inheritance/mod.rsmerging a parent's traits into the child.inheriting
__getwhile declaring a private property was flagged for anaccess PHP dispatches to the handler. The pre-merge shortcut now confirms
only public members.
(1) and (2) are the same mistake in opposite directions: inferring provenance
that is not recorded anywhere. Hence
declaring_class(), and hence D22.Also in here
examples/php/inlay_hints.phpusedfn($u) => $u->name, where$nameisprotectedonModeland the closure lives in a class that does not extend it— a runtime fatal, unnoticed because the method returns an empty array and the
closure is never called. Switched to the public accessor. The new check found
it.
Not included
Backlog entries this PR adds. All are missed reports, not wrong ones:
unset, so the exactmagic handler cannot be required; any of the four suppresses.
$fromFoo::$bar; a static property and asame-named constant are indistinguishable downstream.
Known|OtherwithKnown::$xprivate and no$xonOtherisreported by neither check.
handles neither trait aliases nor nested traits.
replacing it, though the resolver already knows the bound class.
D22 is the one I would like your read on. Recording the declaring class
during the merge deletes this check's provenance walk and closes D23, but adds a
field to every member across the index — worth measuring against current memory
first. Your call; happy to open an issue instead.
Testing
cargo test7120 passed / 0 failed,cargo clippy --all-targets -- -D warningsand
cargo fmt --checkclean.php -lclean,runDemoAssertions()passes,phpantom_lsp analyzeonexamples/laravelstill reports exactly three errors— the new check fires nowhere in that project.
37 tests in
tests/integration/diagnostics_member_visibility.rs. The siblingpair is the one to look at: protected on a shared parent must stay reachable
from a cousin, protected on the sibling itself must not. They pass together only
if provenance is right; either alone passes against broken code.
examples/php/diagnostics.phpgainsMemberVisibilityDemo, with reflectionassertions in
scaffolding/assertions.php.Criterion,
diagnosticsgroup, base vs. head:lots_of_missing_methods(~1175 lines, all unresolved members)method_chainlots_of_missing_methodsis the worst case — every member in it takes thelengthened path.
Checklist
If applicable:
CHANGELOG.mdREADME.md,docs/,examples/)config-schema.json)how it works, how it's organized), including any code drafted by an LLM.
an eye towards deleting anything that is irrelevant, clarifying anything
that is confusing, and adding details that are relevant. This includes,
for example, commit descriptions, PR descriptions, and code comments.
🤖 Generated with Claude Code