Skip to content

fix(ir_lower): resolve a constructor signature through its owner class - #990

Merged
nahime0 merged 3 commits into
mainfrom
fix/868-inherited-private-ctor-defaults
Sep 14, 2026
Merged

nahime0 merged 3 commits into
mainfrom
fix/868-inherited-private-ctor-defaults

Conversation

@Guikingone

@Guikingone Guikingone commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

Closes #868.

Cause

A private method is not inherited, so ClassInfo::methods deliberately carries no __construct entry on a descendant of a class with a private constructor. types::constructor_owner exists to walk to the ancestor that still owns it, and #796 routed the checker and new codegen through it.

Two IR-lowering sites — both named in the issue — kept reading class_info.methods.get("__construct") directly, so such a descendant looked like it had no constructor at all:

Site Symptom
object_construction::constructor_signature no signature → omitted defaults never padded → fixed_new rejects the arity: constructor call to Child::__construct with 0 args for 1 params
reflection_new_instance::constructor_signature_for_class_name no signature → ReflectionClass::newInstance() pads nothing and runs no constructor, leaving promoted properties at their zero values
<?php
class Owner {
    private function __construct(private int $n = 1) {}
    public static function makeChild(): Child { return new Child(); }
    public function n(): int { return $this->n; }
}
class Child extends Owner {}
echo Owner::makeChild()->n();   // php: 1 — elephc: backend error

Fix

Both sites resolve through constructor_owner. An inherited public or protected constructor is unaffected: the walk stops at the instantiated class itself whenever its own map has the entry, and a descendant that replaces the constructor keeps its own for the same reason.

Tests

Six tests in tests/codegen/oop/relative_types.rs, next to the existing constructor_owner coverage:

  • regression — padding an inherited private constructor's defaults on the named path (the fixture above)
  • regressiontest_reflection_new_instance_resolves_an_inherited_private_constructor: measured 6 with the owner walk, 0 without it, and verified to fail on the unpatched lowering
  • an explicitly passed argument still winning over the default
  • the reflection control with a public inherited constructor (it passes either way, since a public constructor is copied into the descendant's map)
  • an inherited public constructor still padding (unchanged path)
  • a descendant that declares its own constructor keeping it (unchanged path)

The reflection regression fixture depends on a separate elephc gap: newInstance() does not yet enforce constructor visibility, where php-src raises Error: Call to private Owner::__construct(). Its docblock says so, and says that closing that gap should turn the fixture into a rejection test rather than delete it — the lookup it pins is what decides which class's constructor the visibility check would then be asked about.

--test codegen_tests oop (810), relative_types (26), --test error_tests (1518) and cargo test --lib pass; cargo build is warning-free. One unrelated flake, elephc-pcntl's priority_failure_warnings_name_the_operation_and_errno, fails only under the parallel suite and passes in isolation — it lives in another crate that this change cannot reach.

Scope

The new static() form in the issue's reproduction is blocked earlier by #797, which rejects a new static() whose constructor has omitted defaults even for a public constructor with no inheritance at all:

<?php
class Plain {
    public function __construct(public int $n = 1) {}
    public static function make(): static { return new static(); }
}
Plain::make();   // dynamic object construction for Plain without EIR-lowered candidates

That path is out of scope here exactly as the issue states ("Not #797 … This is the named/fixed_new owner path after #796"). The dynamic thunk builder in ir_lower::function::lower_dynamic_constructor_thunk has the same methods lookup and will need the same treatment when #797 lands; it is left alone here because no shape can exercise it while #797 stands.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr

Closes #868.

A private method is not inherited, so `ClassInfo::methods` deliberately carries
no `__construct` entry on a descendant of a class with a private constructor;
`types::constructor_owner` exists to walk to the ancestor that still owns it,
and #796 routed the checker and `new` codegen through it.

Two IR-lowering sites kept reading `class_info.methods.get("__construct")`
directly, so such a descendant looked like it had NO constructor at all:

- `object_construction::constructor_signature` — with no signature, omitted
  defaults were never padded and `fixed_new` then rejected the arity:
  `new Child()` on `private function __construct(int $n = 1)` failed with
  `constructor call to Child::__construct with 0 args for 1 params`.
- `reflection_new_instance::constructor_signature_for_class_name` —
  `ReflectionClass::newInstance()` on the descendant saw none either.

Both now resolve through `constructor_owner`. An inherited public or protected
constructor is unaffected: the walk stops at the instantiated class itself
whenever its own map has the entry, and a descendant that REPLACES the
constructor keeps its own for the same reason.

Tests pin the padding on the named path, an explicit argument winning over a
default, the reflection half, and both unchanged shapes (inherited public
constructor, descendant-declared constructor).

The `new static()` form in the issue's reproduction is blocked earlier by #797,
which rejects a `new static()` whose constructor has omitted defaults even for a
PUBLIC constructor with no inheritance involved; that path is out of scope here
exactly as the issue states.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
@github-actions github-actions Bot added area:eir Touches EIR definitions, lowering, validation, or passes. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Sep 13, 2026
@greptile-apps

greptile-apps Bot commented Sep 13, 2026

Copy link
Copy Markdown

Greptile Summary

Resolves constructor signatures through constructor_owner in fixed object construction and reflection lowering, allowing private ancestor constructors to supply omitted defaults.

  • Adds regression coverage for private-ancestor defaults and controls for explicit arguments, public inheritance, and constructor overrides.
  • Both previous findings are fixed: reflection now has a distinguishing regression fixture, and the stopping-condition docblock is corrected.
  • No file changes exist since the previous review SHA, and no new actionable findings were identified.

Confidence Score: 5/5

The PR appears safe to merge, with both previous findings addressed and no new blocking findings.

The private-ancestor reflection fixture now distinguishes owner lookup from direct lookup, and the public-inheritance docblock correctly states that traversal stops when the constructor entry exists. The stopping-condition thread was resolved without an explanatory reply; the code confirms its correction. There are no file changes since the previous review.

Important Files Changed

Filename Overview
src/ir_lower/expr/object_construction.rs Resolves fixed-class constructor metadata through the owner lookup so inherited private-constructor defaults can be padded.
src/ir_lower/expr/reflection_new_instance.rs Uses the same owner lookup for known-class reflection constructor signatures.
tests/codegen/oop/relative_types.rs Adds six regression and control tests, including the previously missing reflection fallback regression, and corrects the stopping-condition documentation.

Reviews (4): Last reviewed commit: "test(oop): add the reflection regression..." | Re-trigger Greptile

Comment thread tests/codegen/oop/relative_types.rs
Comment thread tests/codegen/oop/relative_types.rs Outdated
…ion test's claim

Review follow-up on #990.

The public-constructor docblock stated the owner walk stops at the instantiated
class when its own map LACKS the entry, which is the opposite of what
`constructor_owner` does and of what the test verifies.

The reflection fixture was also labelled a regression test it cannot be: a public
inherited constructor is already copied into the descendant's `methods` map, so
the previous direct lookup supplies its signature too. The two lookups differ only
for a class whose own map lacks `__construct` — the inherited-PRIVATE-constructor
shape — and PHP rejects `ReflectionClass::newInstance()` on a non-public
constructor outright, so no valid program reaches it. The fixture is now labelled
the control it is, and says why the site was changed anyway.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Guikingone added a commit that referenced this pull request Sep 13, 2026
…ion test's claim

Review follow-up on #990.

The public-constructor docblock stated the owner walk stops at the instantiated
class when its own map LACKS the entry, which is the opposite of what
`constructor_owner` does and of what the test verifies.

The reflection fixture was also labelled a regression test it cannot be: a public
inherited constructor is already copied into the descendant's `methods` map, so
the previous direct lookup supplies its signature too. The two lookups differ only
for a class whose own map lacks `__construct` — the inherited-PRIVATE-constructor
shape — and PHP rejects `ReflectionClass::newInstance()` on a non-public
constructor outright, so no valid program reaches it. The fixture is now labelled
the control it is, and says why the site was changed anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Review follow-up on #990.

The reviewer was right that the reflection fixture could not catch a regression:
an inherited PUBLIC constructor IS copied into the descendant's own `methods`
map, so the previous direct lookup supplied its signature too.

My reply then claimed no valid shape could tell the two lookups apart, because
php-src rejects `ReflectionClass::newInstance()` on a non-public constructor.
That was wrong about ELEPHC: it does not yet enforce constructor visibility
there, so the private-ancestor shape is reachable, and the lookup difference is
plainly observable. Measured both ways on the new fixture:

- with the owner walk: `6` — the default is padded and the ancestor's
  constructor runs;
- reading the descendant's own map: `0` — no signature, so no padding and no
  constructor call at all, leaving the promoted property at its zero value.

Added as `test_reflection_new_instance_resolves_an_inherited_private_constructor`,
verified to FAIL on the unpatched lowering. Its docblock records that the shape
depends on elephc's separate `newInstance()` visibility gap, and that closing
that gap should turn this into a rejection test rather than delete it — the
lookup it pins is what decides which class's constructor the visibility check
would then be asked about. The public-inheritance fixture stays as the control
it is, relabelled accordingly.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Guikingone added a commit that referenced this pull request Sep 13, 2026
Review follow-up on #990.

The reviewer was right that the reflection fixture could not catch a regression:
an inherited PUBLIC constructor IS copied into the descendant's own `methods`
map, so the previous direct lookup supplied its signature too.

My reply then claimed no valid shape could tell the two lookups apart, because
php-src rejects `ReflectionClass::newInstance()` on a non-public constructor.
That was wrong about ELEPHC: it does not yet enforce constructor visibility
there, so the private-ancestor shape is reachable, and the lookup difference is
plainly observable. Measured both ways on the new fixture:

- with the owner walk: `6` — the default is padded and the ancestor's
  constructor runs;
- reading the descendant's own map: `0` — no signature, so no padding and no
  constructor call at all, leaving the promoted property at its zero value.

Added as `test_reflection_new_instance_resolves_an_inherited_private_constructor`,
verified to FAIL on the unpatched lowering. Its docblock records that the shape
depends on elephc's separate `newInstance()` visibility gap, and that closing
that gap should turn this into a rejection test rather than delete it — the
lookup it pins is what decides which class's constructor the visibility check
would then be asked about. The public-inheritance fixture stays as the control
it is, relabelled accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
@Guikingone
Guikingone requested a review from nahime0 September 13, 2026 18:44
@Guikingone Guikingone self-assigned this Sep 13, 2026
@Guikingone
Guikingone force-pushed the fix/868-inherited-private-ctor-defaults branch from bb1091d to 744dff6 Compare September 13, 2026 19:39
Guikingone added a commit that referenced this pull request Sep 13, 2026
Closes #871.

#796 introduced `types::constructor_owner` — the walk that finds the class whose
`__construct` PHP runs when a descendant of a class with a non-public
constructor is instantiated — and the existing coverage exercised only the
0-argument private-ancestor success path. The listed gaps are behaviours that
are already CORRECT on main; what was missing is the pins that keep them so.

A new `tests/codegen/oop/constructor_owner.rs` groups the member-visibility half,
where private and protected deliberately diverge:

- a PROTECTED ancestor constructor IS inherited: it runs through the descendant,
  and `method_exists()` is `true` on both classes;
- it is still not public, so the descendant is not instantiable and the reflected
  declaring class is the ancestor;
- a PRIVATE ancestor constructor is NOT inherited: `method_exists()` is `false`
  on the descendant while staying `true` on the declaring class — the half that
  makes the owner walk necessary at all;
- `get_class_methods()` agrees, with an inherited public method alongside so the
  assertion is a contrast rather than a tautology (the function is scope-aware,
  so a private constructor is omitted from its own declaring class too);
- a descendant's own PUBLIC constructor replaces a private ancestor's.

Two error tests cover the hiding direction, which the walk must NOT take: a
descendant that declares its own PRIVATE constructor hides an inherited public
one, so `new static()` from the ancestor's scope and a named `new Child()` both
name the DESCENDANT's constructor, exactly as php-src does
(`Call to private HideChild::__construct() from scope HideOwner`).

Every fixture's output was diffed against reference PHP 8.5 and matches.

The remaining items on the issue — omitted defaults on the owner path, a
descendant replacing the constructor, and `method_exists`/`getMethods` staying
false — are covered by the tests in PRs #990 and #991.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr

@nahime0 nahime0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@nahime0
nahime0 merged commit 1f3f6aa into main Sep 14, 2026
148 checks passed
@nahime0
nahime0 deleted the fix/868-inherited-private-ctor-defaults branch September 14, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:eir Touches EIR definitions, lowering, validation, or passes. size:s Small pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inherited private constructors with default arguments fail arity on the owner path

2 participants