Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
"WebFetch(domain:datatracker.ietf.org)",
"Bash(for f:*)",
"Bash(do)",
"Bash(done)"
"Bash(done)",
"Bash(rm /tmp/phpunit-output.txt)",
"Bash(grep -E \"FAIL|ERROR|WARN|Tests:\" /tmp/phpunit-output.txt; rm /tmp/phpunit-output.txt)"
]
}
}
93 changes: 93 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# CLAUDE.md

## Learning from reviews

After completing a task that involved responding to code review feedback, scan the reviewer's
corrections and confirmations for patterns not already captured in memory or in this file. For
each non-obvious pattern found, write or update a `feedback` memory file in the project memory
directory and add a pointer to `MEMORY.md`.

What qualifies as worth saving:
- Any correction the reviewer had to make that I should have caught myself.
- Any expectation that surprised me or that I applied incorrectly.
- Any confirmation that a non-obvious approach was right (so it is not silently reversed later).

What does not qualify:
- One-off fixes specific to a single schema or class.
- Anything already stated verbatim in this file.
- Trivially obvious mistakes with no generalizable lesson.

Do this at the end of the session, not during — so it does not interrupt implementation work.

---

## Clarification policy

Before starting any non-trivial task — one that has more than one degree of freedom, including
Expand Down Expand Up @@ -189,6 +210,9 @@ a wrapper class here.
After finishing an implementation task, always stage all relevant changed files for commit using
`git add`. Do not wait for the user to ask — stage immediately when the work is done.

Never add `.claude/` files (issues, topics, memory, etc.) to git unless the user explicitly asks.
These are working notes for the session and must not appear in commits.

### Reading files

Always use the dedicated `Read` tool to read file contents. Never use `sed`, `head`, `tail`, `cat`, or `awk` to read or extract portions of files. The `Read` tool supports `offset` and `limit` parameters for reading partial files when needed.
Expand Down Expand Up @@ -224,6 +248,14 @@ Rules:
git history.
- Update the plan file(s) as the work progresses — record decisions made, phases completed, and
any pivots in approach.
- **Record every non-obvious design decision as it is made**: state the option chosen, every
alternative that was considered and rejected, and the reasoning that ruled each alternative out.
A rejected alternative that is not recorded can be silently re-introduced in a later session
when context is compressed. The record must be specific enough that a cold reader can reconstruct
*why* the chosen approach is correct — not just *what* it is. Example: "Classifying `type`
against `$outputTypes` was considered and rejected: a branch `{type: integer}` under a
`stringToInt` filter must validate the raw input, so routing it through output-type matching
would allow string `'50'` to pass a `type: integer` check post-transform."
- Once a topic is **ready to merge**, delete the entire `.claude/issues/<number>/` or
`.claude/topics/<slug>/` directory and commit that deletion as the final commit on the branch,
**before** merging to `master`. The tracking files are working notes and must never land on
Expand Down Expand Up @@ -285,6 +317,59 @@ that works for one specific schema shape but breaks or ignores others is not acc
implementing, ask: "Does this solution handle the general case, or only the example at hand?" If
only the specific case, redesign until the solution is general.

#### Never narrow test scope to evade failures

When a test exposes a real bug, fix the bug — do not simplify, remove, or replace the test with
one that avoids the failing scenario. A failing test is evidence of a defect; discarding it hides
the defect rather than resolving it.

This applies in both directions:
- Never swap a schema or assertion for a simpler variant just because the original triggers an
error in the implementation. The original schema is the spec; make the implementation handle it.
- Never stub out, skip, or weaken assertions to make a test green. If the assertion is wrong,
fix the assertion with an explicit justification; if the implementation is wrong, fix the
implementation.

When the straightforward test case surfaces a deeper issue (object instantiation, type conflict,
priority ordering, etc.), that is precisely the issue that needs solving. Open it as a tracked
topic if it cannot be addressed immediately, but keep the test in place and marked as expected to
fail (`@expectedExceptionMessage`, `$this->expectException(...)`) until the fix lands.

#### No implementation-plan references in code

Do not embed references to implementation-plan phases, issue numbers, or source-code line numbers
in comments, docblocks, filenames, or any other artifact that lands in the repository. These
references decay immediately (phases complete, line numbers shift) and add noise without adding
meaning.

- ❌ `// Phase 2 guarantees anyOf/oneOf have uniform spaces`
- ✅ `// Static rejection guarantees anyOf/oneOf have uniform spaces`
- ❌ `* Covers FilterValidator::runCompatibilityCheck lines 130–158`
- ✅ `* Validates the zero-overlap rejection path in FilterValidator`
- ❌ `* exercises FilterProcessor line 429 (else branch of classifyValidatorAdjustments)`
- ✅ `* exercises the else branch of classifyValidatorAdjustments`

This rule applies equally to DocBlocks in test files: do not reference specific line numbers of
the code under test. Line numbers shift whenever the file is edited, making such references
misleading immediately after refactoring. Describe *what the code does or why* instead.

Describe *what the code does or why* — not where it came from in a planning document.

#### Name the rejected alternative in non-obvious comments

When a decision is non-obvious — especially one where a "natural correction" would silently
re-introduce a wrong approach — the comment must name the rejected alternative and explain why it
fails, not just assert the chosen approach.

- ❌ `// type keyword is always classified as Input`
- ✅ `// Always Input — do NOT classify against outputTypes. A branch {type: integer} under a
// stringToInt filter must validate the raw input; treating it as Output would allow string
// '50' to pass a type: integer check post-transform.`

The same decision must also be covered by a test whose name encodes the specific scenario, so
that any regression surfaces immediately as a named, self-explaining failure rather than a
cryptic assertion error.

#### Test coverage

Every identified edge case must have a corresponding test. During planning, enumerate all edge
Expand All @@ -300,6 +385,14 @@ https://qlty.sh/gh/wol-soft/projects/php-json-schema-model-generator/pull/<PR_NU

Review the coverage report and address any uncovered lines in changed or new code.

### Docblock content

Write docblocks only when they add information beyond what the code already expresses.

- **Omit** class-level `@package` tags and `Class ClassName` lines — the namespace declaration and class keyword already carry that information.
- **Omit** method docblocks whose prose just restates the method name (e.g. `/** Returns the foo. */` above `getFoo(): Foo`). Write a docblock only when it explains *why* something is done, a non-obvious contract, or a type constraint that native PHP cannot express (e.g. `@return SomeClass[]`).
- **Do not copy-paste** identical `@param` descriptions across multiple methods. Each docblock should describe what is specific to that method's use of the parameter.

### Union type style

When rendering union types in generated PHP code, use one space before and after the pipe:
Expand Down
70 changes: 69 additions & 1 deletion docs/source/nonStandardExtensions/filter.rst
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,12 @@ Transforming filter

You may keep it simple and skip this for your first tries and only experiment with non-transforming filters like the trim filter

Filters may change the type of the property. For example the builtin filter **dateTime** creates a DateTime object. Consequently further type-related validations like pattern checks for the string property won't be performed. If you use a transforming filter which transforms the value into another accepted type (eg. your property accepts ['string', 'integer'] and your transforming filter transforms provided strings into integers) the additional provided validators for integers (like minimum or maximum checks) will be executed (only if your property accepts integer values; if the property only accepts strings and the transforming filter converts them to integer values integer validators won't be added to the property). Additionally enum validations will not be executed if an already transformed value is provided.
Filters may change the type of the property. For example the builtin filter **dateTime** creates a DateTime object. When a transforming filter is present, the generator automatically classifies all validators on the property into two groups based on which type-space they target:

- **Input-space validators** (e.g. ``pattern``, ``minLength`` for a string property) run *before* the filter, against the raw input value.
- **Output-space validators** (e.g. ``minimum``, ``maximum`` for an integer returned by a string-to-int filter) run *after* the filter, against the transformed value.

This classification is derived from the Draft type registry and applies to both schema validators and composition branches (see `Composition with transforming filters`_ below). For multi-type properties (e.g. ``['string', 'integer']``) with a transforming filter, validators that target only the string type run pre-transform and validators that target only the integer type run post-transform. Additionally enum validations will not be executed if an already transformed value is provided.

As the required check is executed before the filter a filter may transform a required value into a null value. Be aware when writing custom filters which transform values to not break your validation rules by adding filters to a property.

Expand All @@ -100,6 +105,69 @@ The return type of the transforming filter will be used to define the type of th

If you use a filter on a property which accepts multiple types (e.g. ``['string', 'null']`` or ``['string', 'integer']``), the filter only needs to overlap with **at least one** of those types. Values whose type is not accepted by the filter are silently skipped at runtime. Only if the filter's accepted types have *no overlap at all* with the property's types is a ``SchemaException`` raised at generation time.

Composition with transforming filters
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Composition keywords (``allOf``, ``anyOf``, ``oneOf``, ``if``/``then``/``else``, ``not``) may be combined with a transforming filter on the same property. Each branch is automatically classified at generation time as targeting the **input type-space** or the **output type-space** of the filter:

- **Input-space branches** are evaluated *before* the filter, against the raw input value.
- **Output-space branches** are evaluated *after* the filter, against the transformed value.

A branch that spans both type-spaces raises a ``SchemaException`` at generation time because it cannot be placed correctly in either phase of the pipeline. The following additional constraints are also enforced:

- A filter keyword inside any composition branch always raises a ``SchemaException``, regardless of whether the property itself carries a filter. The composition engine resets the value to the original input after each branch evaluation, which would silently discard any transformation applied inside the branch.
- ``anyOf`` / ``oneOf``: all branches must share a single type-space; cross-space branches raise a ``SchemaException``.
- ``not``: the inner schema must target a single type-space.
- ``if`` / ``then`` / ``else``: all three sub-schemas must share the same type-space.

**Example — input-space allOf** (validates the raw string before the dateTime filter runs):

.. code-block:: json

{
"type": "object",
"properties": {
"scheduledAt": {
"type": "string",
"filter": "dateTime",
"allOf": [
{
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$"
}
]
}
}
}

The ``pattern`` constraint fires against the raw string *before* the ``dateTime`` filter transforms it to a ``DateTime`` object. Passing ``"hello"`` raises an ``AllOfException`` because the pattern fails. Passing ``"2024-01-01"`` passes the pattern and is then converted to a ``DateTime``. Passing an already-constructed ``DateTime`` object bypasses the pre-transform pipeline entirely and is accepted as-is.

**Example — output-space allOf** (validates the integer *after* a string-to-int filter):

.. code-block:: json

{
"type": "object",
"properties": {
"quantity": {
"type": ["string", "integer"],
"filter": "stringToInt",
"allOf": [
{
"minimum": 0,
"maximum": 100
}
]
}
}
}

The property accepts both raw strings (transformed by the filter) and already-converted integers (which bypass the filter). The ``minimum`` and ``maximum`` constraints are output-space and fire against the final integer *after* the filter has run. Passing ``"50"`` is transformed to ``50`` and passes both constraints. Passing ``"200"`` is transformed to ``200`` and fails ``maximum``. Passing the already-transformed integer ``50`` directly skips the filter and is still validated by the output-space allOf.

.. hint::

The ``type`` keyword inside a composition branch always validates against the **raw input value** (before the filter runs). For a ``stringToInt`` filter, a branch like ``{"type": "integer", "minimum": 0}`` mixes an input-space constraint (``type``) with an output-space constraint (``minimum``), which raises a ``SchemaException`` at generation time. Declare the type at the **property level** instead.

Exceptions from filter
----------------------

Expand Down
2 changes: 1 addition & 1 deletion src/Draft/AutoDetectionDraft.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public function getDraftForSchema(JsonSchema $jsonSchema): DraftInterface
{
// Only Draft_07 is currently supported; all schemas (including unrecognised
// or absent $schema keywords) fall back to it. Additional drafts will be
// detected here in later phases (e.g. draft-04, draft 2020-12).
// detected here when additional drafts are added (e.g. draft-04, draft 2020-12).
return $this->draftInstances[Draft_07::class] ??= new Draft_07();
}
}
19 changes: 19 additions & 0 deletions src/Draft/Draft.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ public function hasType(string $type): bool
return isset($this->types[$type]);
}

/**
* Returns the JSON Schema type names (e.g. 'string', 'integer', 'object') whose registered
* modifiers or validator-factories carry the given schema keyword.
*
* @return string[]
*/
public function getTypesForKeyword(string $keyword): array
{
$typeNames = [];

foreach ($this->types as $typeName => $type) {
if (array_key_exists($keyword, $type->getModifiers())) {
$typeNames[] = $typeName;
}
}

return $typeNames;
}

/**
* Returns the Type entries whose modifiers apply to a property of the given type(s).
* The special type 'any' always applies to every property; passing 'any' returns all types.
Expand Down
2 changes: 1 addition & 1 deletion src/Draft/Draft_07.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ public function getDefinition(): DraftBuilder
->addModifier(new NullModifier()))
->addType((new Type('any', false))
->addValidator('enum', new EnumValidatorFactory())
->addValidator('filter', new FilterValidatorFactory())
->addValidator('allOf', new AllOfValidatorFactory())
->addValidator('anyOf', new AnyOfValidatorFactory())
->addValidator('oneOf', new OneOfValidatorFactory())
->addValidator('not', new NotValidatorFactory())
->addValidator('if', new IfValidatorFactory())
->addValidator('filter', new FilterValidatorFactory())
->addModifier(new DefaultValueModifier())
->addModifier(new ConstModifier()));
}
Expand Down
14 changes: 11 additions & 3 deletions src/Model/Property/Property.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,11 @@ public function setExamples(array $examples): PropertyInterface
/**
* @inheritdoc
*/
public function addValidator(PropertyValidatorInterface $validator, int $priority = 99): PropertyInterface
{
public function addValidator(
PropertyValidatorInterface $validator,
int $priority = 99,
?string $sourceKey = null,
): PropertyInterface {
if (!$validator->isResolved()) {
$this->isResolved = false;

Expand All @@ -211,7 +214,12 @@ public function addValidator(PropertyValidatorInterface $validator, int $priorit
});
}

$this->validators[] = new Validator($validator, $priority);
$wrapper = new Validator($validator, $priority);
if ($sourceKey !== null) {
$wrapper->setSourceKey($sourceKey);
}

$this->validators[] = $wrapper;

return $this;
}
Expand Down
13 changes: 11 additions & 2 deletions src/Model/Property/PropertyInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,17 @@ public function setExamples(array $examples): PropertyInterface;
* Priority 10+: Filter validators
* Priority 99: Default priority used for casual validators
* Priority 100: Validators for compositions
*/
public function addValidator(PropertyValidatorInterface $validator, int $priority = 99): PropertyInterface;
*
* The optional $sourceKey records which schema keyword (e.g. 'pattern', 'minimum')
* caused this validator to be added. Normally set automatically by PropertyFactory
* after each Draft modifier runs; pass it explicitly only when transferring a validator
* from another property (e.g. multi-type sub-property transfer).
*/
public function addValidator(
PropertyValidatorInterface $validator,
int $priority = 99,
?string $sourceKey = null,
): PropertyInterface;

/**
* @return Validator[]
Expand Down
9 changes: 6 additions & 3 deletions src/Model/Property/PropertyProxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,12 @@ public function setExamples(array $examples): PropertyInterface
/**
* @inheritdoc
*/
public function addValidator(PropertyValidatorInterface $validator, int $priority = 99): PropertyInterface
{
return $this->getProperty()->addValidator($validator, $priority);
public function addValidator(
PropertyValidatorInterface $validator,
int $priority = 99,
?string $sourceKey = null,
): PropertyInterface {
return $this->getProperty()->addValidator($validator, $priority, $sourceKey);
}

/**
Expand Down
22 changes: 22 additions & 0 deletions src/Model/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
*/
class Validator
{
private ?string $sourceKey = null;

public function __construct(protected PropertyValidatorInterface $validator, protected int $priority)
{}

Expand All @@ -25,4 +27,24 @@ public function getPriority(): int
{
return $this->priority;
}

public function setPriority(int $priority): void
{
$this->priority = $priority;
}

/**
* The schema keyword (e.g. 'pattern', 'minimum') that caused this validator to be added,
* as determined by the Draft modifier registry. Null for validators not produced by a
* Draft AbstractValidatorFactory (e.g. TypeCheckValidator, RequiredPropertyValidator).
*/
public function getSourceKey(): ?string
{
return $this->sourceKey;
}

public function setSourceKey(?string $sourceKey): void
{
$this->sourceKey = $sourceKey;
}
}
Loading
Loading