Skip to content

Unevaluated properties - #157

Open
wol-soft wants to merge 49 commits into
masterfrom
unevaluated-properties
Open

wol-soft wants to merge 49 commits into
masterfrom
unevaluated-properties

Conversation

@wol-soft

@wol-soft wol-soft commented Jun 28, 2026 •

Copy link
Copy Markdown
Owner

Implements the JSON Schema 2019-09 keywords unevaluatedProperties and unevaluatedItems with full integration into the library's feature set: composition (allOf / anyOf / oneOf / if-then-else / not), $ref resolution, mutable models (setters, populate(), accessor mutators), error collection and direct-exception mode, serialization, transforming filters, and the accessor post-processor family.

Both keywords consume the annotation results of sibling applicators: a key/index counts as evaluated only when a sibling applicator — including a successful composition branch — actually claimed it at runtime. A static approximation is impossible under anyOf/oneOf/conditionals, so the generated code tracks branch outcomes at validation time.

Object side — unevaluatedProperties

  • UnevaluatedPropertiesValidator (schema form) / NoUnevaluatedPropertiesValidator (false form) with UnevaluatedPropertiesValidatorFactory, registered on the object type for Draft 2019-09.
  • Runs in a new post-composition validation phase (Schema::addPostCompositionValidator() → _executePostCompositionValidators() on the generated model), guaranteeing spec ordering: properties → propertyNames → patternProperties → additionalProperties → compositions → unevaluatedProperties.
  • The evaluated set is rebuilt on demand from: declared property names, patternProperties matches with passing values, and the per-branch composition cache (see below). Keys the schema-form validator itself accepts are recorded in _evaluatedPropertyKeys so an enclosing schema's unevaluatedProperties credits them, and nested branch classes expose their evaluated set via an internal _getEvaluatedProperties() method.

Array side — unevaluatedItems

  • UnevaluatedItemsValidator / NoUnevaluatedItemsValidator with factory, registered on the array type at priority 101 so the validator runs after composition validators (priority 100) in the property's validator chain; non-array values of multi-typed properties short-circuit via an is_array() guard.
  • Property-level array compositions write the union of successful branches' claimed indices into a transient _compositionAnnotated slot per composition validator (slot keys <property>_<n>); branch claims are derived from the branch's items (schema/tuple form), additionalItems, and runtime contains matches.
  • contains support: the anonymous contains validator was promoted to a named ArrayContainsValidator with an opt-in per-index match map, so a composition branch can credit exactly the indices its contains matched (including combined items + contains branches and minContains: 0).
  • Nested unevaluatedItems inside a successful branch credits its indices to the outer accumulator through the shared _evaluatedItemIndices field, with snapshot/rollback so a failing branch leaks nothing.

Runtime evaluation tracking (composition integration)

  • ComposedItem.phptpl and ConditionalComposedItem.phptpl write per-branch results into the _compositionEvaluations cache when tracking is activated: branch claims from declared properties / pattern matches / non-false additionalProperties, or a reference to the nested branch instance. Failed branches contribute nothing; whole-composition failure restores the pre-run cache; not branches are unconditionally rolled back (negative applicators never produce annotations).
  • ConditionalPropertyValidator now extends AbstractComposedPropertyValidator, bringing if/then/else under the same tracking machinery (slots for if, then, else; a succeeding if contributes its own claims alongside then).
  • Activation is lazy: an internal UnevaluatedPropertiesPostProcessor walks the schema graph (composition branches, nested schemas, $ref targets — cycle-safe) and only instruments schemas that can actually reach one of the keywords. Schemas without the keywords generate unchanged output.
  • Runtime helpers live in the production library's new CompositionEvaluationTrait (collectEvaluatedProperties(), collectUnevaluatedKeys(), collectUnevaluatedIndices()).

Mutability and cross-state validation

  • Named setters build a candidate raw-input view and run the post-composition phase against it before committing, so a mutation that orphans a previously-claimed key (e.g. a oneOf discriminator flip) is rejected.
  • populate() validates the merged candidate state the same way; rollback now uses a Schema-level registry (addRollbackProperty() / addAccessorCacheProperty()) instead of hard-coded field lists in Populate.phptpl.
  • _setAdditionalProperty / _removeAdditionalProperty gained full cross-state discipline (composition re-runs against the candidate state, snapshot/rollback of caches and collections on rejection) so dynamic-key mutations interact correctly with unevaluated tracking.

Opt-in accessor — UnevaluatedPropertiesAccessorPostProcessor

Follows the accessor-pattern from the additional/pattern-properties post processors:

$generator->addPostProcessor(new UnevaluatedPropertiesAccessorPostProcessor());

$model->unevaluatedProperties()->getAll();
$model->unevaluatedProperties()->get('key');
$model->unevaluatedProperties()->set('key', $value);   // mutable models
$model->unevaluatedProperties()->remove('key');        // mutable models
  • Typed schemas produce a {Model}UnevaluatedProperties companion class with narrowed signatures; untyped schemas use the production-library UnevaluatedPropertiesAccessor / ImmutableUnevaluatedPropertiesAccessor.
  • The _setUnevaluatedProperty shim guards against keys that belong elsewhere — declared properties, patternProperties matches, and names/patterns declared inside composition branches (harvested recursively, reported with RFC 6901 pointers via RegularPropertyAsUnevaluatedPropertyException).
  • Emission policy: the accessor (and the backing _unevaluatedProperties collection) is only generated when unevaluated keys are actually reachable; a sibling additionalProperties of true / false / {schema} suppresses it entirely.

Generation-time diagnostics

  • Dead-code matrices with warnings (validator emission skipped): object side for any sibling additionalProperties shape incl. the denyAdditionalProperties() flag; array side for items: false, schema-form items, and tuple items + additionalItems: false.
  • SchemaException for invalid keyword values (non-boolean, non-object).
  • All new validators are stamped with RFC 6901 JSON pointers; pointer utilities were extracted to Utils\JsonSchema (shared by the accessor post processors), and RenderHelper gained PCRE-ready pattern export helpers.

Serialization and filters

  • _unevaluatedProperties is flattened into toArray() / toJson() output; a UnevaluatedPropertiesSerializer.phptpl handles transforming filters (e.g. DateTime values), mirroring the additional-properties serializer.
  • TransformingFilterOutputTypePostProcessor processes the unevaluated validation property so filtered values keep working through the accessor set() path.

Infrastructure changes

  • RenderQueue now runs all post processors before rendering any class, so a post processor may attach methods to nested branch schemas regardless of queue order.
  • ExtractedMethodValidator method names are made instance-unique to fix a collision when two identical composition schemas exist on one class.
  • php-micro-template requirement bumped to ^1.11.0.

Production library (branch jsonSchemaDraft2019 of wol-soft/php-json-schema-model-generator-production)

New classes this branch depends on: Accessor/UnevaluatedPropertiesAccessor, Accessor/ImmutableUnevaluatedPropertiesAccessor, Traits/CompositionEvaluationTrait, and the exceptions UnevaluatedPropertiesException, InvalidUnevaluatedPropertiesException, UnevaluatedItemsException, InvalidUnevaluatedItemsException, RegularPropertyAsUnevaluatedPropertyException (all pointer-aware). A tagged release + version constraint must replace the branch pin before this reaches master.

Documentation

  • object.rst / array.rst: dedicated sections for both keywords (forms, exception surfaces, dead-code behaviour).
  • Every combined-schema page (allOf, anyOf, oneOf, if, not): a "Property and item evaluation propagation" section, including the spec-mandated omitted-vs-explicit additionalProperties distinction.
  • references.rst note on $ref contribution, a new unevaluatedPropertiesAccessorPostProcessor page, fixes to the additional-properties accessor page, and several pre-existing Sphinx warnings resolved.

Tests

Extensive coverage under the multi-draft expansion infrastructure (Draft 2019-09 + 2020-12): validator behaviour in both error modes, composition propagation (incl. $ref-resolved branches and recursive self-references), object- and array-side mutability suites, accessor round-trips with filters and serialization, dead-code warnings, and generation-time error cases.

Postponed / known limitations

  • prefixItems (Draft 2020-12) and dependentSchemas annotation contribution — deferred until those keywords land; a skipped test pins the intended dependentSchemas behaviour.
  • Compositions nested inside a branch do not yet bubble their claims into the outer branch's contribution (both sides).
  • External $ref as a composition branch and self-referencing array compositions are blocked by pre-existing limitations (composition nested-schema expectation; type-hint recursion) — both tracked via incomplete tests.
  • No accessor for unevaluatedItems by design: items remain accessible through the typed property; the validator is a pure assertion.

🤖 Generated with Claude Code

wol-soft and others added 22 commits May 28, 2026 23:19
Introduces the scaffolding needed for unevaluatedProperties/unevaluatedItems
tracking without changing any existing validation behaviour:

- AbstractComposedPropertyValidator: add trackEvaluation flag (getter/setter)
  so later phases can gate _compositionEvaluations cache emission per validator.
- Schema: add postCompositionValidators list (addPostCompositionValidator /
  getPostCompositionValidators) for validators that must run after all
  composition validators complete.
- Model.phptpl: extend executeBaseValidators with a third bucket that iterates
  schema.getPostCompositionValidators(); update both guard conditions to trigger
  the method when either the base-validator list or the post-composition list is
  non-empty. Template variables are not used for the new bucket — the schema
  object is already in context and the method is called directly.
- UnevaluatedPropertiesPostProcessor (new internal post processor): activation
  walk via DFS over composition branches and nested schemas, breaking cycles
  through a file+pointer seen-set; sets trackEvaluation on matching validators
  and emits the _compositionEvaluations cache property when activation triggers.
- ModelGenerator: register UnevaluatedPropertiesPostProcessor immediately after
  CompositionValidationPostProcessor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
# Conflicts:
#	tests/AbstractPHPModelGeneratorTestCase.php
#	tests/PostProcessor/EnumPostProcessorTest.php
When unevaluatedProperties or unevaluatedItems is reachable from a schema,
each composition validator now records the per-branch evaluation outcome in
a typed _compositionEvaluations slot: null when the branch failed, true when
the branch evaluated every property (non-false additionalProperties), an
array of evaluated property names for an inline branch, or the instantiated
branch object for a nested-schema branch. The shared evaluation logic moved
to the production-library CompositionEvaluationTrait so the slot writes
don't duplicate the same loop across every generated class.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three improvements layered on the Phase 2 tracking infrastructure:

Pre-decode patternProperties patterns at generation time. CompositionPropertyDecorator
now exposes getBranchDeclaredPropertyNamesPhpLiteral() and
getBranchPatternPropertyPatternsPhpLiteral() returning var_export'd PHP array
literals ready for direct template embedding. Templates drop the per-name
quoting foreach loop, and the trait no longer calls base64_decode on every
preg_match. var_export also handles all string escaping, so property names
containing single quotes or backslashes no longer produce broken generated
code.

Skip the tracking write on a cache hit. ComposedItem.phptpl initializes
$shouldTrack = true per branch iteration; the mutable-validator cache-hit
branch sets it to false so the success-tracking block is bypassed and the
slot value from the previous run is preserved. On a cache hit $value still
holds the pre-validator array rather than the nested-schema object, so the
old unconditional write would overwrite a valid object slot with null.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# Conflicts:
#	src/Model/Validator/ConditionalPropertyValidator.php
#	src/Templates/Model.phptpl
#	tests/Basic/PhpAttributeTest.php
Adds the runtime validator for `unevaluatedProperties: false` and
`unevaluatedProperties: <schema>` on top of the existing composition
evaluation tracking. The validator iterates the model keys left over
after subtracting the local `properties`/`patternProperties` set plus the
contributions of every successful sibling composition branch recorded in
`_compositionEvaluations`.

The factory skips emitting any validator when the same schema declares a
non-false `additionalProperties`: that keyword already claims every
remaining key, so unevaluatedProperties would be a no-op.

The cross-schema modification needed for nested branch classes
(getEvaluatedProperties) is now applied during `process()`. To keep that
visible at render time, `RenderQueue::execute` runs every job's
`process()` pass before any `render()` begins. The shared
evaluated/unevaluated computation lives on `CompositionEvaluationTrait`
in the production library; both validator templates collapse to a single
trait-method call.

Adds `RenderHelper::varExportPcrePatterns()` to wrap raw
patternProperties keys with `/`-delimiters and escape embedded `/`. The
existing patternProperties consumers (`AdditionalPropertiesValidator`,
`AdditionalPropertiesPostProcessor`, `CompositionPropertyDecorator`) are
migrated to it, fixing a latent crash on patterns containing slashes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# Conflicts:
#	tests/PostProcessor/AdditionalPropertiesAccessorPostProcessorTest.php
#	tests/PostProcessor/PatternPropertiesAccessorPostProcessorTest.php
# Conflicts:
#	src/Model/Validator/AbstractComposedPropertyValidator.php
#	src/Model/Validator/ComposedPropertyValidator.php
#	src/Templates/Validator/ComposedItem.phptpl
The dev-jsonSchemaDraft2019 production library widened MaxItemsException
to require an actual count alongside the maxItems limit. The items: false
branch of ItemsValidatorFactory still passed only the maxItems literal
(via [0]), producing a 3-arg call that broke against the 4-arg constructor.
Mirror the working pattern from MaxItems / MinItems factories: define
$count inside the validation expression and pass it as the fourth
constructor argument by reference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces UnevaluatedPropertiesAccessorPostProcessor, an opt-in post
processor that exposes the keys claimed by `unevaluatedProperties` via a
single getter on the model: `$model->unevaluatedProperties()` returns either
the bare production-library accessor (untyped schema) or a generated
companion class `{ModelName}UnevaluatedProperties` with narrowed get/set/
getAll/remove signatures (typed schema). Private mutator shims
`_setUnevaluatedProperty` / `_removeUnevaluatedProperty` host the runtime
guard and per-call validation; the accessor's closures bind to them via
first-class callable syntax. The shim guard rejects keys that match a
directly-declared property name, a local `patternProperties` pattern, or
any property name/pattern declared inside a composition branch (allOf /
anyOf / oneOf / if / then / else, walked recursively) — those keys belong
to a different contract and routing them through the unevaluated bucket
would silently bypass the typed validator.

Accessor emission honours the keyword's reachability at this schema level:
when `additionalProperties: false` rejects every extra or
`additionalProperties: {schema}` claims every extra, the unevaluated bucket
is unreachable and nothing is emitted (no backing field, accessor method,
shims, companion, or collect flag) — the validator continues to run as a
pure assertion.

UnevaluatedPropertiesValidator gains a `setCollectUnevaluatedProperties`
flag (mirroring the additional-properties pattern) so the validator writes
into `$this->_unevaluatedProperties` after per-key validation succeeds,
with a rollback on whole-validator failure to preserve the prior bucket
state. SerializableTrait in the production library learns to flatten
`_unevaluatedProperties` back into `toArray()` / `toJSON()` output via a
new `_serializeUnevaluatedProperties()` method.

Populate.phptpl's hardcoded rollback / accessor-cache property lists are
replaced with two registries on Schema (`addRollbackProperty`,
`addAccessorCacheProperty`). Existing internal post processors register
their fields into the registries; the new accessor registers
`_unevaluatedProperties` and `_unevaluatedPropertiesAccessor`. The change
is a no-op for any schema that does not opt into the unevaluated accessor.

Test suite (UnevaluatedPropertiesAccessorPostProcessorTest, 15 tests, all
Drafts 2019-09 and 2020-12): typed round-trip; constraint-violation
rejection in direct-exception mode with rollback verification; runtime
guards (declared property, composition-branch property); companion-class
naming and method surface; immutable read-only variant; serialization
round-trip; dead-code suppression; collect-errors-mode setter isolation
(proves the per-call error-registry reset is load-bearing); coexistence
matrix asserting the additionalProperties × unevaluatedProperties emission
policy when both accessor post processors are configured simultaneously.

CLAUDE.md gains an expanded patterns list and recovery procedure for the
"no implementation-plan references in code" rule so that section numbers,
decision identifiers, and similar artifacts of working notes can be
caught before they land.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Splits _executeBaseValidators into two phase methods so the constructor,
setters, and populate can invoke whichever phases they need against
whichever input they need. The schema-level validators that depend on the
merged state of every property — currently just unevaluatedProperties — now
live in _executePostCompositionValidators, and the property-level plus
composition validators stay in _executeBaseValidators.

Constructor calls each method in turn against the raw input. Setters call
_executePostCompositionValidators against a candidate raw input (current
state with the new value plugged in) before commit, so a setter that flips
a oneOf discriminator and would orphan a key from the previous branch's
contract is rejected before the model state changes. populate() runs the
delta check via _executeBaseValidators (which no longer includes the
post-composition phase) and then runs _executePostCompositionValidators
against the would-be merged state; failure rolls every committed property
field back and leaves _rawModelDataInput untouched.

The accessor's _setUnevaluatedProperty shim now calls
_executePostCompositionValidators directly: a single dynamic key only ever
needs the post-composition phase, not the schema's full base + composition
chain. Each call resets its own error registry so a caller catching a
failed set() can continue to use the model.

Test class UnevaluatedPropertiesMutabilityTest covers the setter rollback
path on each composition shape (oneOf, anyOf overlap, anyOf sole-coverer,
if/then/else), the no-op early-return path, populate's merged-state
rollback, the collect-errors mode collation of property-validator failures
together with unevaluated-properties failures in a single registry, and
the cross-state revalidation reading through nested-branch class instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wire cross-state revalidation into _setAdditionalProperty and
_removeAdditionalProperty so dynamic-key mutations see the post-mutation
state. Set runs base validators followed by _executePostCompositionValidators
against a candidate raw input. Remove additionally re-runs every composition
validator against the post-removal raw input so _compositionEvaluations
reflects the new branch outcomes, then runs the post-composition phase. Both
share a single _errorRegistry across phases in collect-errors mode and
snapshot/rollback _additionalProperties, _rawModelDataInput,
_compositionEvaluations, and _propertyValidationState on rejection.

Fix the PHP 8.4 dynamic-property deprecation triggered when a composition
branch declares additionalProperties:{schema}: setupBranchDefaultHelpers no
longer propagates internal properties from a branch's nested schema into
allBranchDefaultAttributeMap. Internal properties have no getter and the
parent never declares the corresponding field, so the propagation loop's
$this->$attr = ... writes were creating dynamic properties on the parent.

Emit a generation-time warning when unevaluatedProperties is suppressed
because a sibling additionalProperties is non-false (the dead-code cell from
the §4.1 matrix). Gate the warning on isOutputEnabled() for parity with the
rest of the warning channel; gate the previously-unconditional echo in
EnumPostProcessor::filterValuesByDeclaredType the same way; let the test
infrastructure respect the caller's outputEnabled so warning-assertion tests
can opt in.

Test coverage added for §5.1.20 (required + unevaluated error precedence in
both direct and collect modes), §5.1.A1 (defaults are not part of the
evaluated set), §5.1.A3/A4 (branch-level additionalProperties per-key
validity), §5.1.25 (pattern + unevaluated bucket coexistence), §5.1.27
(empty allOf), §5.1.M33-34 (additionalProperties accessor under outer
unevaluatedProperties), §5.1.M37 (remove clears backing field plus raw
input), §5.1.M42 (claimed-via-unevaluated key persists after sibling later
covers), the dead-code suppression warning, the Set cross-state revalidation
rejection path, and the dual-error collect-mode aggregation for both Set
(pattern + unevaluated, paired with the per-key validity fix on the
production library) and Remove (min-property + unevaluated via composition
revalidation flipping an anyOf branch). Both dual-error tests assert the
full ErrorRegistryException message via heredoc.

CLAUDE.md adds a rule requiring multi-line exception-message assertions to
use heredoc inlined into the assertSame call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A composition branch with its own unevaluatedProperties:{schema} now
contributes the keys it evaluates against that schema to an enclosing
unevaluatedProperties through the same nested-class read-through the trait
already uses for declared properties.

UnevaluatedPropertiesPostProcessor adds an internal _evaluatedPropertyKeys
field to any schema carrying the schema-form UnevaluatedPropertiesValidator.
UnevaluatedProperties.phptpl writes the key into this field on per-key
validation success, with snapshot/rollback mirroring the existing
_unevaluatedProperties discipline.

Nested branch classes gain an _getEvaluatedProperties() method (underscore
prefix + #[Internal]) returning the union of declared properties present in
the raw input and keys recorded in _evaluatedPropertyKeys. The underscore
sidesteps a name collision with a user-declared evaluatedProperties
property's auto-generated getter and the attribute documents the role.

testNestedUnevaluatedInBranchPropagatesClaimsToOuter runs in collect-errors
mode so a single ErrorRegistryException carries the full chain: the outer
allOf failure, the nested class's invalid-unevaluated-property cause, and
the outer unevaluatedProperties: false rejecting the orphaned foo and bar.
The assertion uses heredoc to compare the full message, with an inline
comment explaining why foo appears as an orphan despite passing the
branch's local properties.foo validator — failed composition branches
contribute no annotations to the outer accumulator under JSON Schema
2019-09's applicator rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduces UnevaluatedItemsValidator and NoUnevaluatedItemsValidator on the
array side, mirroring the property-side flavours. The validator runs at
priority 101 on the array property's validator chain (after composition at
100), wrapping its check in is_array($value) so it short-circuits cleanly
for union types like ["array", "null"]. Both flavours render through a
new collectUnevaluatedIndices() trait method that reads
\$this->_evaluatedItemIndices defensively (the field arrives in a later
stage) and walks \$this->_compositionEvaluations slots filtered by
'kind' => 'array' so the property-side and array-side rebuilds cannot
contaminate each other on mixed-type oneOf.

UnevaluatedItemsValidatorFactory skips emission for absent and true
keywords, throws a SchemaException for non-bool/non-object values, and
warns through the existing echo channel for the three array-side dead-code
shapes — items:false, items:{schema}, and additionalItems:false with tuple
items — that make the keyword unreachable. The property-side factory gains
the same SchemaException guard for non-bool/non-object unevaluatedProperties
values (an oversight that the array-side test surfaced).

Draft_2019_09 registers the factory on the array type. The unevaluated post
processor's needsActivation walk now inspects each property's own JSON
before recursing into its nested schema, so a schema like
{type: object, properties: {tags: {type: array, unevaluatedItems: false}}}
activates the outer object's evaluation tracking (array properties carry
no nested schema, so the previous loop missed them).

The production library gains UnevaluatedItemsException and
InvalidUnevaluatedItemsException. The false-form message reports indices
with the # prefix used by the rest of the array-side family
(InvalidItemException, InvalidTupleException); the schema-form wrapper
preserves the per-index inner exceptions verbatim, so consumers catching
the wrapper can still surface the underlying InvalidTypeException reason.

CLAUDE.md adds two enforcement rules. "Always review the diff against the
repo rules before staging" prescribes a grep recipe for plan/stage/
phase/section references and applies symmetrically to coordinated
production-library checkouts. "Pre-existing rule violations in touched
files" requires sweeping every visible CLAUDE.md violation in any file
edited as part of a task, with one escape hatch when cleanup would balloon
into an unrelated refactor.

UnevaluatedItemsValidatorTest covers the bare false and schema forms
(per-index # notation), uniqueItems-precedence ordering, the four dead-code
warning shapes via expectOutputRegex, and the SchemaException path for
non-bool/non-object values on both keywords. The schema-form test asserts
the full multi-line exception message via heredoc using two failing indices
and pins getInvalidItems() to confirm each failing index's
InvalidTypeException survives the wrapper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Property-level allOf/anyOf/oneOf/if-then-else compositions on array
properties now contribute their successful branches' claimed indices to a
sibling unevaluatedItems validator. The bridge is a single transient
_compositionAnnotated field indexed by slot key, written wholesale at end
of each composition IIFE, read by the unevaluatedItems template via the
prod-lib trait method.

The activation walk in UnevaluatedPropertiesPostProcessor descends
recursively through composition branches' wrapped properties (so nested
compositions get tracking too) and flags any ArrayContains validators
inside tracked branches with trackBranchMatches, which makes the contains
template export a per-index match map captured by the surrounding
composition body.

Sharp edges fixed along the way:
  * ExtractedMethodValidator method-name hash mixed in spl_object_hash so
    two compositions on the same property no longer collide into one
    method body
  * Activation walk reads source validators via getWrappedProperty()
    because PropertyProxy::getOrderedValidators() returns fresh
    withProperty() clones on every call — flag mutations on the clones
    never reached the rendered instance
  * Composition IIFE leaves $value untouched when slotKey is set so a
    failing composition does not null the caller's array value and
    suppress the downstream unevaluatedItems check in collectErrors mode
  * activateArrayComposition has a hash-keyed cycle guard so a self-
    referencing schema does not recurse indefinitely through $ref-induced
    composition cycles

Requires php-micro-template 1.11.0 for the {# ... #} template-time
comment syntax used by ComposedItem.phptpl. CLAUDE.md adds two rules
surfaced by the work: never prefix local variables with underscore (the
prefix is reserved for class members); when a bug is found, write a
reproducing test before fixing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The post processor previously added `_compositionEvaluations` and
`_compositionAnnotated` unconditionally on every activation-triggering
schema, justified by a comment claiming the trait reads them on
no-composition schemas and PHP 8.2 would deprecate the undeclared-
property access. Both halves of that justification were wrong: the
trait's reads go through `?? []`, which does not trigger the dynamic-
property deprecation, and the read sites only execute when the caller
passes composition slot keys — empty on no-composition schemas. The
fields were padding every activation-triggering generated class with
two unused arrays.

Each field now follows the precedent set by `addEvaluatedPropertyKeysField`
and is declared only when something will write to it. The two activation
passes have been extracted into `activateSchemaLevelTracking` (handles
the root-level composition activation and `_compositionEvaluations`) and
`activateArrayPropertyTracking` (handles the array-property walk plus
`_compositionAnnotated` and `_evaluatedItemIndices`); `process()` now
just orchestrates them.

- `_compositionEvaluations` — only when at least one
  `AbstractComposedPropertyValidator` at the schema level had evaluation
  tracking enabled.
- `_compositionAnnotated` — only when the array-property activation walk
  actually enabled tracking on a property-level composition.
- `_evaluatedItemIndices` — new field, declared only when at least one
  array property carries `unevaluatedItems` (the future write site is in
  `UnevaluatedItems.phptpl`; the field is empty until then).

`UnevaluatedItemsMutabilityTest` exercises what Stage 5.3's transient-
field design relies on without any new validator wiring: the whole-array
setter rebuilds composition annotations against the candidate array and
re-runs `unevaluatedItems` against it (rejecting an array whose tail
index no branch claims, preserving pre-setter state on rejection);
`populate()` with array data of a different length flows through
`PopulatePostProcessor`'s revalidation hook and behaves the same. Both
tests use `setCollectErrors(false)` so the raw `UnevaluatedItemsException`
surfaces directly, mirroring the existing `UnevaluatedPropertiesMutability
Test` pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The unevaluatedItems template now writes _evaluatedItemIndices per
successfully-validated index and snapshots/restores the field around
the IIFE so a nested unevaluatedItems inside a composition branch
contributes its claims to the enclosing accumulator on success and
leaves no partial writes behind on failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Verifies the activation walk already handles $ref-resolved schemas through
the property tree it traverses (via getNestedSchema on properties and
composition branches). No generator changes required.

Coverage:
- unevaluatedProperties expressed as {$ref: "#/$defs/foo"}
- $ref-resolved allOf branch contributes annotations to outer accumulator
- Recursive self-$ref terminates and enforces unevaluatedProperties at
  every nesting depth (validated with full-message assertions unwrapping
  through NestedObjectException::getNestedException)
- External $ref to ExternalSchema placeholder used as composition branch:
  markTestIncomplete, blocked by pre-existing composition-processor
  requirement that every composed branch surface a nested schema (same
  limitation as the array-side self-referencing test)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…uated-properties

# Conflicts:
#	src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php
The merge from jsonSchemaDraft2019 landed pointer stamping on every
validator factory but surfaced two follow-up gaps in the unevaluated
work.

ConditionalPropertyValidator did not update
templateValues['compositionValidator'] at render time.
withJsonPointer() clones the validator, but the clone's template
still pointed at the pre-clone original whose
evaluationTrackingEnabled flag is false. Every if/then/else
composition consequently emitted no _compositionEvaluations writes,
so an if-branch that claimed a property left the outer
unevaluatedProperties accumulator empty and the key was reported as
unevaluated. Late-bind in getCheck() the same way
ComposedPropertyValidator already does.

Neither unevaluated factory (object nor array) chained
->withJsonPointer() on the emitted validator, so
UnevaluatedItems / UnevaluatedProperties / InvalidUnevaluated*
exceptions carried an empty pointer. SetUnevaluatedProperty.phptpl's
two RegularPropertyAsUnevaluated throw-sites emitted no pointer at
all. Wire real pointers through three shapes:

- Factory-level: /path/to/property/unevaluatedItems and
  /unevaluatedProperties, matching the sibling stamping in
  additional/pattern factories.
- Object-property harvest (root declaration): resolved via each
  property's synthesized #[JsonPointer] attribute, matching how
  AdditionalPropertiesAccessorPostProcessor resolves it.
- Composition-branch harvest (inline branch declaration): walked
  recursively with per-branch pointers, so a property declared
  inside /allOf/0 gets /allOf/0/properties/<name>. Pattern-based
  entries carry their /patternProperties/<encoded-regex> pointer
  verbatim.

Pointer utilities extracted to Utils\JsonSchema. encodePointer and
decodePointer moved off the Model\SchemaDefinition\JsonSchema data
class where they had been static bystanders; resolvePrimaryJsonPointer
extracted from the two accessor post processors that had duplicated
it. All call sites aliased-import as JsonSchemaUtil to avoid a name
clash with the model class.

RenderHelper::varExportPcrePatternMap added so a template can
foreach over a regex => pointer map when the pattern-guard needs
both fields at runtime.

Pointer assertions added to the acceptance tests that already caught
the message text and payload. The pattern-focused accessor test's
fixture regex changed from `^x_` to `^s/~` so the emitted pointer
becomes `/patternProperties/^s~1~0`, exercising both RFC 6901
replacements and their ordering in a single assertion. The three
RegularPropertyAsUnevaluatedPropertyException catches now also assert
the full "Couldn't add regular property … as unevaluated property to
object …" message.

Requires the coordinating production-library commit 7bfa040
(jsonPointer arg added to UnevaluatedItems / UnevaluatedProperties /
RegularPropertyAsUnevaluatedProperty / MinContains / MaxContains).
Push that commit and \`composer update\` to lock the new SHA.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
UnevaluatedPropertiesValidatorFactory now consolidates all four
sibling shapes that leave the unevaluatedProperties bucket
permanently empty into a single deadCodeReason() helper:

- additionalProperties: true — every extra flows to the model but
  the accumulator does not credit it, so the validator would still
  fire and defeat the intent of `additionalProperties: true`.
- additionalProperties: {schema} — every extra is claimed and
  validated by additionalProperties; the unevaluated set is empty.
- additionalProperties: false — every extra is rejected before the
  post-composition phase, so the unevaluated validator never runs.
- denyAdditionalProperties() generator flag with additionalProperties
  absent — synthesises the same false shape at configuration time.

Each cell emits a distinct warning via the existing echo channel
so build-output greps can identify the specific dead shape, and
skips validator emission entirely.

Tests added:

- Object-side dead-code data provider covering all four shapes plus
  the denyAdditionalProperties() variant. Rows pass the
  GeneratorConfiguration directly (setOutputEnabled(true) on the
  base, chained with setDenyAdditionalProperties(true) on the deny
  row) so the test signature drops the closure/fallback boilerplate.
- testAdditionalFalseRejectsExtrasEvenWhenUnevaluatedSchemaWouldAccept
  proves the factory suppressed the validator: an extra that would
  satisfy the unevaluated integer schema is still rejected because
  additionalProperties: false runs first.
- testContradictoryInnerSchemaThrowsSchemaExceptionPointingAtFile
  pins that an unevaluatedProperties schema with contradictory allOf
  types surfaces via the existing "conflicting types" SchemaException
  path, with the file identifier preserved.
- testPropertyNamesRejectionPrecedesUnevaluated data-provider variant
  covering both direct-exception and error-collection modes. The
  registry message pin includes the base-phase propertyNames block
  followed by the post-composition InvalidUnevaluatedPropertiesException
  block; template uses `{className}` + str_replace for interpolation.
- testDependentSchemasContributionCreditsDependentPropertiesToAccumulator
  is skipped (dependentSchemas applicator not yet implemented) but
  asserts both the intended success path (kind + covered `extra`)
  and the failure path (kind + non-covered `stray`) so removing the
  skip line makes the applicator's contract live.
- Array-side testContainsWithMinContainsZeroCreditsMatchedIndicesOnly
  and testOneOfBranchesOfDifferentTupleLengthsControlEvaluatedSet
  cover contains + minContains: 0 and mixed-tuple-length composition.

Object-side dead-code fixtures cover all six sibling shapes;
denyAdditional variant paired with the config row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coveralls

coveralls commented Jul 7, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 31972611204

Coverage increased (+0.03%) to 98.906%

Details

  • Coverage increased (+0.03%) from the base build.
  • Patch coverage: 10 uncovered changes across 5 files (1087 of 1097 lines covered, 99.09%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
src/SchemaProcessor/PostProcessor/Internal/UnevaluatedPropertiesPostProcessor.php 171 165 96.49%
src/Model/Validator/AdditionalPropertiesValidator.php 3 2 66.67%
src/PropertyProcessor/PropertyFactory.php 76 75 98.68%
src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php 227 226 99.56%
src/Utils/JsonSchema.php 9 8 88.89%
Total (43 files) 1097 1087 99.09%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 9051
Covered Lines: 8952
Line Coverage: 98.91%
Coverage Strength: 575.6 hits per line

💛 - Coveralls

wol-soft and others added 6 commits July 7, 2026 18:53
Adds resolveNestedClassName() to the abstract test case so generated
class names can be discovered and interpolated into expected exception
messages instead of matched via \S+/\w+ regex. Replaces the regex-based
assertions across four test files with literal-string assertions,
tightening what "the message must contain" checks were previously
letting through.

Also adds two confirmatory fixtures and tests for patternProperties /
additionalProperties value-subschemas that declare their own
unevaluatedProperties: false, verifying that the inner class
self-activates through its own post-processing pass without needing the
parent's activation walk to recurse into these sibling schemas.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the docs Phase 8 work (unevaluatedProperties/unevaluatedItems sections in
object.rst/array.rst, per-composition propagation notes, a dedicated accessor
post-processor page) plus the pre-existing sphinx warnings we hit along the
way.

While adding filter-companion tests, two transforming-filter regressions on
the unevaluated side surfaced: the set() shim rejected already-transformed
values because the pre-transform type-check was not pass-through-wired, and
toArray() dropped transformed values because no filter-aware serializer
override was emitted. TransformingFilterOutputTypePostProcessor and
SerializationPostProcessor now iterate post-composition validators the same
way they already iterate base validators, and a new
UnevaluatedPropertiesSerializer template mirrors the additional-side
serializer.

Also updates the additional-properties accessor doc to match its actual
companion signatures and drops one unused import in FilterProcessor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Every behavioural case the reflection test asserted (allOf key contribution,
anyOf per-branch success/fail, not-branch rollback, if/then/else slot layout)
is covered end-to-end by UnevaluatedPropertiesValidatorTest via observable
acceptance and rejection assertions. Inspecting the internal
_compositionEvaluations cache added no coverage the validator tests do not
already provide and coupled the test to a private field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The if-null-nested-schema block in generateValidatorPropertyMap() cannot
execute for the composition validators that method iterates.
SchemaProcessor::transferComposedPropertiesToSchema() throws SchemaException
for any schema-level composed property that lacks a nested schema, and
inheritPropertyType() forces branches to adopt the parent's object type so
PropertyFactory routes every inline branch through createObjectProperty() and
sets the nested schema. Setter-side revalidation for inline oneOf, anyOf, and
if-then-else discriminators is already covered by the strict path iterating
getNestedSchema()->getProperties(), asserted end-to-end by the mutability
tests exercising kind and mode discriminator flips.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ten deliberately failing tests pin the spec-correct behaviour for the
defects surfaced by the review of the unevaluatedProperties /
unevaluatedItems implementation. Each test turns green when its fix
lands:

- AutoDetectedDraftSubschemaTest (new, carrying no ApplicableDrafts
  attribute so the default AutoDetectionDraft stays active): the draft
  detected from the document root's $schema URI must apply to property
  and nested subschemas, not only to the root level
- sibling applicator crediting on the array side: tuple items, contains
  matches, and an explicit items: true must credit their indices to the
  unevaluatedItems accumulator
- a composition branch carrying unevaluatedItems without the keyword on
  the array property itself currently renders code calling the missing
  collectUnevaluatedIndices() trait method
- branch-level unevaluatedProperties: true and a nested-class branch's
  patternProperties matches must be credited to the outer accumulator
- object applicators declared without an explicit type: object must not
  be silently dropped
- unevaluatedProperties()->set() must reject a value that a successful
  branch's additionalProperties claims instead of committing it to the
  raw model data while leaving it invisible to the accessor

Expected messages are pinned against wrapping formats captured from
equivalent already-working paths (composition summary in direct-exception
mode, nested-object wrapping); the nested class name is matched by regex
where the generated class does not exist yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Array items (schema-form items, tuple-form items, and unevaluatedItems) now
behave consistently with object properties: a transforming filter's output
is persisted into the property's own storage, an already-transformed value
is accepted directly, and mixed raw/transformed lists validate independently.
TransformingFilterOutputTypePostProcessor and SerializationPostProcessor now
recurse into ArrayItemValidator/ArrayTupleValidator/UnevaluatedItemsValidator
the same way EnumPostProcessor already does, with a cycle guard for
self-referencing schemas and a combined serializer so tuple and
unevaluatedItems filters on the same property can't clobber each other's
generated method name.

Also fixes a validation-bypass bug surfaced by the above: _evaluatedItemIndices
was never reset between separate validation passes, so a setTags() call could
silently skip validating (and re-filtering) an index whose value had
completely changed since a previous pass. Reset now happens before any
validator runs for the property, ahead of composition validators that may
credit the same slot earlier in the same pass.

Migrates the remaining spl_object_hash usages in this package to
spl_object_id, since all are internal identity-tracking uses (method-name
suffixes, array-key deduplication) never serialized or exposed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Base automatically changed from jsonSchemaDraft2019 to master August 4, 2026 10:09
wol-soft and others added 21 commits August 12, 2026 23:24
Fix the review findings on unevaluatedProperties/unevaluatedItems:

- AutoDetectionDraft caches the document dialect per file so a root
  $schema reaches every subschema; default dialect is now 2020-12 and
  a nested resource $schema no longer leaks to sibling subschemas.
- Direct sibling array applicators credit their evaluated indices: a
  tuple items (and non-false additionalItems tail) deterministically,
  a sibling contains by recording matched indices, and items: true is
  treated as dead code that claims every index.
- A composition branch carrying unevaluatedItems without the keyword on
  the array property now activates the evaluated-index field.
- A branch-level unevaluatedProperties: true and a nested-class branch's
  patternProperties matches are credited to the outer accumulator.
- Rename emitted catch variables to $exception (or drop the unused
  capture) and remove throwaway loop values in generated code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An untyped property resolved to type 'any', and PropertyFactory ran only the
'any' Draft entry. Every type-specific applicator -- properties,
patternProperties, additionalProperties, unevaluatedProperties, minLength,
minItems, ... -- was therefore silently dropped. JSON Schema applicators are
not gated on a type declaration, so they must apply whenever the instance is
of the relevant type.

Route the 'any' case to createUntypedProperty, which wires three layers onto a
single permissive property:

- Object applicators generate the nested class (type: object is forced only on
  the copy handed to processSchema) and wire gated instantiation plus the
  instanceof guard via ObjectModifier. The nested-class type it stamps is reset
  to null afterwards and replaced by a `Nested | mixed` type hint, so the getter
  stays permissive -- the slot may hold the nested object or any other value the
  schema accepts. TypeCheckModifier(object) is deliberately not wired: it would
  hard-reject non-object values.
- The universal 'any' modifiers run once, as before.
- Concrete-type validator factories run for the remaining types. Each factory
  self-guards on keyword presence and each emitted validator self-gates on the
  runtime type, so only present keywords contribute a validator and none of them
  constrain the value's type. The non-factory type-shaping modifiers
  (TypeCheck, IntToFloat, DefaultArrayToEmptyArray, Null) are skipped because
  they are not keyword-gated and would reshape any-typed values.

A bare {} activates neither the object nor the scalar layer and behaves exactly
as before, so no empty nested classes are generated.

Extract the validator source-key tagging into a shared runModifier helper and
simplify createTypedProperty, which is no longer reachable with 'any'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unevaluatedProperties()->set() and additionalProperties()->set()
accessors could commit a value that a successful composition branch
claims and rejects, leaving the model in a state its own constructor
would refuse. Two independent defects caused this; either alone leaves
the behaviour broken.

_setUnevaluatedProperty never ran the composition validators at all —
its comment wrongly asserted they cannot apply to a single dynamic key.
A branch keyword that reaches keys the branch does not declare
(additionalProperties, patternProperties, unevaluatedProperties,
propertyNames, min/maxProperties) does decide whether the key is
admissible. The setter now runs each _validateComposition_N against the
candidate pair before the post-composition phase, with the same
rollback discipline the additional-properties setter uses.

The setter-side composition cache in ComposedItem.phptpl decided
"nothing relevant changed" by intersecting the mutated keys against the
branch's declared property names. A dynamic key never intersects that
list, so the cache reported a hit and the branch was skipped —
defeating even the additional-properties setter, which already invoked
the validators. branchEvaluationDependsOnUndeclaredKeys() now gates the
cache read: a branch whose outcome an undeclared key can flip is never
served from the cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three defects in composition-aware setter/remove revalidation, all
pre-existing and surfaced while fixing the dynamic-key setter. Each was
reproduced red before the fix.

A plain setter for a declared property never revalidated a composition
branch whose outcome depends on keys the branch does not declare (a
branch minProperties/maxProperties/additionalProperties/...). The
composition post processor mapped a validator only to the properties
its branches declared, so a sibling property declared on the outer
schema but on no branch got no revalidation call and could commit a
value the branch rejects. A key-sensitive branch now maps its validator
to every declared property.

_removeUnevaluatedProperty ran only its local minProperties check and
performed no composition or post-composition revalidation, unlike the
additionalProperties remove path it should mirror. Removing a key could
flip a branch (dropping below a branch minProperties, orphaning a key a
branch used to claim) and leave a model its own constructor would
reject. The remove template now snapshots the mutated fields, revalidates
the post-removal state, and rolls back on rejection.

The composition template caches each branch's outcome in
_propertyValidationState for every mutable composition, but the field
was declared only when some branch declared a property. A composition
whose branches declare none (e.g. allOf: [{minProperties: 3}]) left the
field undeclared, so the template's write created it dynamically — a
deprecation as of PHP 8.4. The field is now declared for every mutable
composition regardless of branch shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts:
#	CLAUDE.md
#	composer.json
#	docs/source/combinedSchemas/not.rst
#	docs/source/generic/references.rst
#	src/Draft/AutoDetectionDraft.php
#	src/Model/Property/CompositionPropertyDecorator.php
#	src/Model/Validator/AbstractComposedPropertyValidator.php
#	src/Model/Validator/Factory/Arrays/ItemsValidatorFactory.php
#	src/PropertyProcessor/PropertyFactory.php
#	src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/RemoveAdditionalProperty.phptpl
#	src/SchemaProcessor/PostProcessor/Templates/AdditionalProperties/SetAdditionalProperty.phptpl
#	src/SchemaProcessor/PostProcessor/Templates/Populate.phptpl
#	src/Templates/Validator/ArrayContains.phptpl
#	src/Templates/Validator/ComposedItem.phptpl
#	src/Templates/Validator/ConditionalComposedItem.phptpl
#	src/Templates/Validator/SchemaDependency.phptpl
#	src/Utils/RenderHelper.php
#	tests/Basic/SchemaDependencyTest.php
#	tests/ComposedValue/ComposedAllOfTest.php
#	tests/Objects/ArrayPropertyTest.php
RemoveAdditionalProperty.phptpl, SetAdditionalProperty.phptpl, and
RemoveUnevaluatedProperty.phptpl each had a literal space before a
{% if %}...{% endif %} block emitting @throws. When the condition is
false the block renders nothing, leaving the docblock line as a bare
" *" with a trailing space. Moved the space inside the conditional so
it only appears alongside actual content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Recovers testCompositionNestedInsideArrayBranchIsNotSilentlyDropped and
its fixture, which had only ever been git-stashed before the master
merge and never actually landed on the branch. Marked incomplete: a
composition nested inside a composition branch on an array-typed
property is silently dropped during generation, a pre-existing gap in
the composition rendering path unrelated to unevaluatedItems itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the three remaining Phase 2 coverage-gap-test-plan items:

- A bare boolean `true` composition branch under `unevaluatedProperties`
  contributes nothing to the evaluated set and does not crash on the
  branch's absent nested schema.
- `unevaluatedItems` declared on a non-array-typed property is silently
  ignored (no array-index validator is emitted), while a sibling
  `unevaluatedProperties: false` still activates normally.
- `if`/`then`/`else` on an array property under `unevaluatedItems`
  credits whichever of `then`/`else` actually ran, exercising
  ConditionalComposedItem.phptpl's array-side branch for the first
  time. No defect found — this closes a coverage gap in already-
  correct code, not a bug fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ng patternProperties

AdditionalProperties.phptpl's pattern-exclusion loop referenced an
undefined $property instead of the enclosing foreach's $propertyKey.
Every key, matching the pattern or not, hit preg_match($pattern, null),
which throws a TypeError under PHP 8's strict internal-function typing
-- the surrounding catch (\Exception $exception) does not catch
TypeError, so construction crashed uncaught for any input on any
schema combining additionalProperties: {schema} with a sibling
patternProperties, not just producing a wrong validation result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the remaining Phase 3 coverage-gap-test-plan items:

- Proves the composition cache-hit skip for a setter whose property
  isn't wired to any _validateComposition_N call, via reflection-based
  state corruption (a value that would fail revalidation, left
  untouched to prove the setter never re-validates).
- Proves _setUnevaluatedProperty's same-value early return actually
  skips validation/rollback bookkeeping, not just that revalidating
  the same valid value happens to succeed, via an error-registry
  sentinel-identity check.
- Direct-exception-mode counterpart to the existing collect-errors
  test for a remove() that both drops below minProperties and flips a
  composition branch: only the first applicable exception surfaces,
  and rollback restores state.
- harvestCompositionPropertyNames() recursion into patternProperties
  and a composition nested inside a branch.
- The untyped (bare production-library class) unevaluatedProperties
  accessor and denyAdditionalProperties() suppressing the accessor
  entirely.

Two items closed with no test needed: 3.2 was already covered by an
existing test using the harness's direct-exception-mode default. 3.1's
existing test method was renamed and rewritten in place since its
docblock and assertions did not actually prove a cache skip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds regression coverage for harvestCompositionPropertyNames()'s
if/then/else recursion, found by cross-referencing a fresh coverage
report against the earlier gap analysis: allOf/anyOf/oneOf and
if/then/else are two separate recursive code paths in the harvester,
and the existing nested-composition test only exercised oneOf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cross-referenced a fresh --coverage-clover run against the original
gap analysis (57 uncovered lines) rather than trusting behavioral
verification alone. Closes two of the four remaining lines:

- UnevaluatedItemsValidatorFactory's dead-code classifier falls
  through to "not dead code" for items: null (not a valid items
  value) rather than misclassifying it as one of the three recognised
  dead-code shapes.

Also documents a genuine, previously-undiscovered bug found while
tracing the other two lines: a composition branch's own
unevaluatedProperties never sees properties the enclosing schema (or
a sibling branch) declares -- only the reverse direction (branch
claims propagating up to an outer unevaluatedProperties) was ever
built. `{properties: {name}, allOf: [{unevaluatedProperties: false}]}`
wrongly rejects {name: "Alice"} even though name is declared and
validated by the enclosing schema. Root cause and revisit trigger are
in implementation-plan.md item 20; surfaced here via markTestIncomplete
since the fix touches how a branch's nested class computes its
evaluated set, not a single call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tion

Post-implementation-review items 13 and 17 both described "composition
nested inside a composition branch on an array-typed property" but
turned out to be two independent bugs at different layers, not one:

- Item 17 (activation-detection gap): UnevaluatedPropertiesPostProcessor
  never recursed into a branch's own nested composition when the branch
  is array-typed (no nested Schema to recurse through via
  getNestedSchema(), unlike object-typed branches). Result: a fatal
  "Error: Call to undefined method ...::collectUnevaluatedIndices()" --
  the innermost branch's unevaluatedItems validator rendered and called
  into CompositionEvaluationTrait, but the trait was never added to the
  class. Fixed by recursing through the branch's wrapped property's own
  validators, mirroring how activateValidatorsInBranch() already walks
  this exact structure for the activation step itself.

- Item 13 (annotation-aggregation gap): once activation is correctly
  triggered, a branch that is itself purely a nested composition (no
  direct items/contains of its own) never had its nested composition's
  correctly-tracked claimed indices read back into its own tracked
  slot, so the outer unevaluatedItems: false over-rejected every index
  instead of just the ones actually left over. Fixed by
  CompositionPropertyDecorator::getNestedCompositionSlotKeys(), unioned
  into the branch's evaluated-index set in ComposedItem.phptpl.

Both fixtures were re-tested against each fix in isolation to confirm
the two bugs are independent, not the same defect surfacing twice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
warnIfVacuousBranch() never reached a `not: {}` branch because
NotValidatorFactory never forwarded which branch index inherited its type
from the parent - inheritPropertyType() now returns that index set (tuple
return replaces the easy-to-forget by-ref out-param) instead of silently
dropping it for one composition keyword.

Also fixes TypeCheck::buildNegatedJsonSchemaTypeCheck() wrongly rejecting an
empty JSON object `{}` for a multi-type object candidate whenever its sibling
type wasn't `array` (json_decode can't tell `{}` from `[]`, and the existing
'array'-side carve-out had no 'object'-side mirror).

Both found via code review; each ships with a red-then-green regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Profiled UnevaluatedPropertiesPostProcessor::needsActivation() against a
synthetic worst-case schema (many top-level classes sharing one deep $ref
chain, no unevaluatedProperties anywhere to short-circuit the walk). Cost
tracks (containers x depth) as predicted but stays in the 2-7% band of total
generation time even under this adversarial shape - not a hot path, and a
persistent Schema-level cache would carry real invalidation risk against
post processors that still mutate schemas after the walk runs. Closed
without adding caching; see the plan for the full measurement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… audit

Walked every composition-branch occurrence of the nine undeclared-key-sensitive
keywords across the test fixture set (43 real hits) and traced through how
additionalProperties/patternProperties/propertyNames/unevaluatedProperties are
scoped per-branch, not per-schema: a branch's own declared names are the only
ones excluded from its own undeclared-key check, so any other schema property
(declared by a sibling branch or the base schema) is genuinely relevant, not
merely a conservative safety margin. No real fixture shows the "map every
property" fallback over-mapping in practice. Closed without changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Measured the two isolatable components of overhead since a clean before/after
checkout isn't available on this branch (155 commits diverged from master,
spanning unrelated multi-draft work): the new unevaluatedProperties tests are
3.5% of total suite wall-clock, and the always-on UnevaluatedPropertiesPostProcessor
adds a 5.1% marginal generation-time tax to schemas that never opt into the
feature (measured via reflection A/B over 137 real existing fixtures) -
combined estimate lands in the 6-9% range, under the item's own >10%
investigate-further threshold. Closed without adding activation-walk caching,
consistent with item 1.

Also adds a tracked, deliberately-failing regression test for a genuine but
unrelated bug found while assembling the benchmark batch: a schema whose
derived class name collides with a PHP-reserved word (e.g. "ReadOnly", "readonly"
reserved since PHP 8.1) generates uncompilable PHP instead of failing cleanly
at generation time. Full analysis and a related second collision case (with
the generated class's own fixed imports) in
.claude/topics/reserved-php-classname-collision/analysis.md - deferred as a
separate topic, out of scope for the unevaluated-properties feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CompositionTypeHintDecorator::getTypeHint() recursed indefinitely on a schema
like {type: array, allOf: [{$ref: "#"}], unevaluatedItems: false} - the
composed branch's wrapped property carries the same decorator instance again.
Fixed with a per-instance recursion-depth guard mirroring
ArrayTypeHintDecorator's existing pattern for the analogous cycle; on
re-entry it skips itself for the nested getTypeHint() call instead of
reapplying, without degrading type hints for genuinely nested (non-cyclic)
composition.

Fixing that let generation get further and surface a second, previously
undocumented recursion in the same subsystem:
UnevaluatedPropertiesPostProcessor::propertyHasBranchUnevaluatedItems()
recurses into a branch's wrapped property with no cycle guard whenever the
branch has no nested Schema - true for every array-typed branch, so true for
this same self-referencing shape. Fixed with a $seen map keyed on
file+pointer (not object identity, since getOrderedValidators() returns
fresh clones per call), mirroring needsActivation()'s existing schema-level
guard.

Both verified red-then-green by reverting each fix in isolation and
confirming Xdebug's own infinite-loop guard catches the un-fixed method.

A third, deeper bug surfaced once generation started succeeding:
constructing an instance still stack-overflows, because allOf: [{$ref: "#"}]}
applied directly to a property (not through items) is a degenerate
constraint with no base case. Verified this doesn't affect the actually
useful items: {$ref: "#"} recursive pattern, which works correctly. Deferred
as its own topic (.claude/topics/self-composition-runtime-recursion/) since
a real fix is a design decision (reject at generation time vs. runtime cycle
memoization), not a mechanical one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ty audit

Read all 7 flagged call sites in PropertyFactory.php, RefResolver.php,
PropertyAttributeSynthesizer.php, and SchemaProcessor.php in full context -
every one is already correctly guarded (an early-return guard clause, a
directly-preceding null check, a ternary, or safe by construction). No fix
needed.

The stress fixture built to raise confidence beyond static reading (an
anyOf-based mutual-reference pair, exercising the two least-obviously-covered
SchemaProcessor.php sites) surfaced a much larger, separate bug instead:
constructing an instance of a mutually-referencing anyOf/oneOf composition of
objects stack-overflows, since each branch must stay independently
instantiable (unlike allOf, which flattens mutually-referencing branches at
generation time - verified by inspecting the existing passing allOf fixture's
generated output). Folded into the runtime-recursion topic item 15 already
opened, since it changes that topic's fix-option recommendation. Surfacing
test added (generation succeeds; construction is known to hang, so the test
stops short of it and is marked incomplete).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Investigated whether the down-propagation gap (a composition branch's own
unevaluatedProperties/unevaluatedItems can't see names/indices the enclosing
schema or a sibling branch already claims) is tractable to fix now. It is
not, as a single change: the enclosing schema's own declared names are
static, generation-time-known information that could in principle be baked
into a branch's generated validator call, but a sibling branch's claims can
be instance-dependent and branches validate one at a time in a single
forward pass (confirmed in ComposedItem.phptpl), so a spec-correct fix needs
a two-pass evaluation model, not a call-site patch. Shipping only the
tractable half was considered and rejected - it would leave the
sibling-branch direction silently broken behind a passing test that never
exercises it.

Confirmed the array side has the identical defect (the item's own "revisit
trigger" had left this unverified): a sibling tuple items claim on an array
property is invisible to an allOf branch's own unevaluatedItems: false.
Added a markTestIncomplete regression test mirroring the existing
object-side one, so the gap has a tracked, failing surfacing mechanism on
both sides.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…agation

A composition branch's own unevaluatedProperties/unevaluatedItems has no
visibility into property names or indices declared by the enclosing schema
or a sibling branch - only the reverse direction (branch to enclosing) is
implemented. A real fix would need a two-pass evaluation model, which is
provably ambiguous for anyOf/oneOf (two sibling branches can each gate on
the other's claims with no unique consistent outcome). Rather than ship a
partial fix or silently produce incorrect validation, generation now throws
a new UnsupportedSchemaFeatureException (a SchemaException subclass) when
this unsupported shape is detected, across allOf/anyOf/oneOf/if/then/else
(not is exempt by design).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.

2 participants