Skip to content

Commit db580a3

Browse files
committed
added AGENTS.md & DOCS
1 parent 13296a0 commit db580a3

3 files changed

Lines changed: 282 additions & 0 deletions

File tree

.gitattributes

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
.gitattributes export-ignore
22
.github/ export-ignore
33
.gitignore export-ignore
4+
AGENTS.md export-ignore
45
ncs.* export-ignore
56
phpstan*.neon export-ignore
7+
docs/ export-ignore
68
tests/ export-ignore
79

810
*.php* diff=php

AGENTS.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# To My Agents!
2+
3+
It is my fervent wish that this file guide every AI coding agent working with code in this repository.
4+
5+
## Documentation
6+
7+
Any distilled, agent-facing documentation for this package - how it works
8+
internally and the rationale behind key design decisions - lives in `docs/`.
9+
Consult it before non-trivial changes; it is the source of truth from which the
10+
public manual is distilled.
11+
12+
Small package, one coherent but tricky mechanism (the `Processor` pipeline over
13+
`Elements/*`). Read `docs/internals.md` before editing it - the "phase" model is
14+
subtler than it looks.
15+
16+
## Project Overview
17+
18+
**Nette Schema** validates and normalizes data structures (config files, API
19+
inputs) through a fluent `Expect::` builder and a `Processor`.
20+
21+
- **PHP Version**: 8.1 - 8.5
22+
- **Package**: `nette/schema` (dep: `nette/utils`)
23+
24+
## Essential Commands
25+
26+
```bash
27+
# Run all tests
28+
vendor/bin/tester tests/Schema/ -s # or: composer tester
29+
vendor/bin/tester tests/Schema/Expect.structure.phpt -s
30+
31+
# Static analysis (PHPStan level 8)
32+
composer phpstan
33+
```
34+
35+
## Conventions
36+
37+
- Every file starts with `declare(strict_types=1);`; **tabs**; single quotes;
38+
`@internal` for implementation details, `@method` for `Expect`'s magic methods;
39+
Nette Coding Standard.
40+
- Tests are Nette Tester `.phpt` named `Expect.<feature>.phpt`; `checkValidationErrors()`
41+
asserts the expected error messages of a failing `process()`.
42+
43+
## Working in this repo
44+
45+
- **There is no `validate()` phase.** The `Schema` interface has four operations
46+
but `Processor` runs only two per call: `process()` = `normalize()` +
47+
`complete()`; `processMultiple()` = `normalize()` each item, `merge()`
48+
left-to-right, one `complete()`. **Validation happens inside `complete()`**;
49+
`merge()` is reached only via `processMultiple`. Don't trust the old "three-phase"
50+
description.
51+
- **`before()` runs per dataset item; `transform()`/`assert()` run once** on the
52+
merged result - so a `before` sees one config layer, a `transform` sees the whole.
53+
- **Errors accumulate in `Context`, never thrown mid-validation.** Each element's
54+
`complete()` is an `$isOk = $context->createChecker(); $isOk() && nextStep()`
55+
short-circuit chain - thread any new validation step through the checker or it
56+
runs on already-rejected values.
57+
- **`PreventMerging` (`'_prevent_merging'`) is in-band control metadata** injected
58+
into the data and stripped-and-honored differently in ~5 places (Type/Structure/
59+
AnyOf/Helpers). Any new element must reproduce the dance or merging misbehaves.
60+
- **`assert`/`castTo` are sugar over `transform`** - one `$transforms` list running
61+
in declaration order, so `->assert()->castTo()` differs from `->castTo()->assert()`.
62+
- **`default` null is not `nullable`** (`nullable()` prepends `'null|'` to the type
63+
string); a `null` value coerces to `[]` when the default is an array. **`Structure`
64+
is required-by-default and casts to `object`, so `default()` throws on it.**
65+
- **`AnyOf` tries variants in order in a throwaway `Context` clone** - losing
66+
variants' side effects (including transforms) are discarded. `DynamicParameter`
67+
values get **deferred** validation (recorded in `Context::dynamics` for DI) -
68+
don't validate them eagerly.
69+
- User-facing how-to (the `Expect::` API, castTo/`Expect::from` object mapping,
70+
building complex schemas) is manual material and lives in the public web docs.

docs/internals.md

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# Schema internals
2+
3+
How `nette/schema` validates and normalizes data underneath, for agents editing
4+
it. Small package, one coherent mechanism (the `Processor` pipeline over
5+
`Elements/*`), so one file.
6+
7+
## The contract and where validation actually lives
8+
9+
`Schema` (`src/Schema/Schema.php`) declares four operations, but they are **not**
10+
four phases. `Processor` has two public entry points and calls only two of them
11+
per run:
12+
13+
- `process()``normalize()` then `complete()` (each followed by
14+
`throwsErrors()`).
15+
- `processMultiple()``normalize()` each dataset item, `merge()` them
16+
left-to-right, then a single `complete()`.
17+
18+
**Validation is not a separate step — it happens inside `complete()`.** Type
19+
checking, range, pattern, item recursion, default merging, and transforms all run
20+
there (`Type::complete`). `merge()` is reached **only** through
21+
`processMultiple`. `completeDefault()` runs for items missing from the input.
22+
Reading the interface as "normalize / validate / complete" (as older docs do)
23+
will mislead you: there is no `validate()`.
24+
25+
A non-local consequence for `processMultiple`: **`before()` hooks run per dataset
26+
item** (inside each `normalize`, before the merge), but **`transform()`/`assert()`
27+
run once** on the already-merged result (inside the single `complete`). So a
28+
`before` sees one config layer at a time; a `transform` sees the whole.
29+
30+
## Error accumulation is the control-flow spine
31+
32+
Errors are **collected in `Context`, never thrown mid-validation** (`Context`,
33+
`Processor::throwsErrors` — yes, with the typo — fires only between phases).
34+
The mechanism that makes this
35+
work is `Context::createChecker()`: it snapshots the current error count and
36+
returns a closure that is `true` only while no new error has been added.
37+
38+
Every element's `complete()` is a chain guarded by that closure:
39+
40+
```php
41+
$isOk = $context->createChecker();
42+
Helpers::validateType(...);
43+
$isOk() && Helpers::validateRange(...);
44+
$isOk() && ... && $value = $this->doTransform(...);
45+
```
46+
47+
**This `$isOk() && ...` short-circuit is the invariant to preserve.** Each step
48+
runs only if every prior step stayed clean, so later logic never sees a value an
49+
earlier check already rejected. Add a validation step without threading it through
50+
the checker and you will validate/transform garbage. `doTransform` re-arms its own
51+
checker so a transform that reports an error stops the remaining transforms.
52+
53+
`Processor::createContext()` builds a fresh `Context` per run and invokes the
54+
public `Processor::onNewContext` closures on it — the official hook by which
55+
other packages attach (nette/di plugs in here, e.g. to consume `dynamics`).
56+
Reshaping `Context` or `createContext` breaks them invisibly.
57+
58+
## Adding errors: the `Message` contract
59+
60+
`Context::addError(template, code, variables)` stores a `Message`; rendering is
61+
deferred to `Message::toString()`. Templates use `%placeholder%` substitution:
62+
`label`, `path` and `value` are filled in automatically (`addError` injects
63+
`isKey`, which flips `%label%` between "item" and "key of item"; `addWarning`
64+
does not). A placeholder whose value is `null` vanishes together with the space
65+
before it — that is how `%path%` disappears at the root. Codes are the
66+
`Message::*` string constants, whose docblocks list the expected variables; a
67+
placeholder with no matching variable triggers an undefined-array-key warning
68+
in `toString()`, so keep template and variables in sync.
69+
70+
## `PreventMerging`: in-band metadata, handled in many places
71+
72+
The magic array key `Helpers::PreventMerging` (`'_prevent_merging'`) is
73+
injected **directly into the data** to mean "replace, don't merge with the base /
74+
default". Because it rides inside the value, **every element must detect and strip
75+
it** — and they do so in subtly different ways:
76+
77+
- `Type::normalize` strips it, then **re-adds** it after recursing into items (so
78+
it survives normalization).
79+
- `Type::complete` strips it and forces `$merge = false` (default not merged in).
80+
- `Type::merge` / `AnyOf::merge` / `Helpers::merge` strip it and return the value
81+
as-is (no merge).
82+
- `Structure::merge` strips it and sets `$base = null` (full replace).
83+
84+
This is the package's sharpest trap: a piece of control state travelling through
85+
the payload, replicated across five sites. Any new `Schema` element must reproduce
86+
the strip-and-honor dance or merging silently misbehaves. (There is a standing
87+
idea to replace it with a declarative `MergeMode::Replace`; DI carries its own
88+
parallel `PREVENT_MERGING` constant. See `docs/local/ideas/odstranit-prevent-merging.md`.)
89+
90+
## One transform pipeline; `assert`/`castTo` are sugar over `transform`
91+
92+
`before()`, `transform()`, `assert()`, and `castTo()` are **not** independent
93+
stages. `before` runs in `normalize` (pre-validation) and has a **single slot**:
94+
a second `before()` call silently replaces the first. Everything else
95+
appends to a single `$transforms` list (`Base`): `castTo` is
96+
`transform(getCastStrategy(...))`, `assert` is a `transform` that reports an error
97+
and returns null on failure. They therefore execute in **declaration order** in
98+
one `doTransform` pass, after type/range/pattern validation. Reordering
99+
`->assert()->castTo()` vs `->castTo()->assert()` changes what each sees.
100+
101+
## `default` null is not `nullable`
102+
103+
- A `Type`'s `default` is `null`, but the type does **not** accept `null` unless
104+
`nullable()` was called — which works by prepending `'null|'` to the type
105+
string (`Type::nullable`), not by a flag. `dynamic()` similarly prepends
106+
`DynamicParameter::class . '|'`.
107+
- **null-to-empty-array coercion:** `complete()` turns a `null` value into `[]`
108+
whenever the default is an array, with the comment "NEON cannot distinguish null
109+
from an empty array". The check is **unconditional — it fires even after
110+
`nullable()`**, so a nullable array-typed item never yields `null`, and a NEON
111+
key written bare (`key:`) validates as an empty array.
112+
113+
## Keys validate like values — and collapse on failure
114+
115+
`arrayOf(value, key)` runs the key schema through the same
116+
`normalize`/`complete` cycle as values, with `Context::isKey` set around the
117+
call (`Type::normalize`, `Type::validateItems`); `Message::toString` renders
118+
such errors as "key of item". The trap: a key that fails `complete()` comes
119+
back as `null` and lands in `$res[$key ?? '']`, so **all invalid keys silently
120+
collapse into a single `''` entry**, later ones overwriting earlier ones.
121+
122+
## Structure specifics
123+
124+
- **Required by default and casts to object.** The constructor sets
125+
`$required = true` and calls `castTo('object')`, so a `Structure` yields a
126+
`stdClass` and `default()` **throws** — it cannot have one.
127+
- **A missing required structure still fills nested defaults.**
128+
`completeDefault` completes `[]` through the normal path (recursively producing
129+
every child's default). That path includes `doDeprecation`, so a deprecated
130+
structure emits its warning even when merely absent from the input.
131+
- **`skipDefaults` has two independent switches** — the `Processor`
132+
(`Context::skipDefaults`) and the `Structure` — and `validateItems` fills in a
133+
default only when **neither** asks to skip it.
134+
135+
## AnyOf: first clean variant wins, in a throwaway context
136+
137+
`findAlternative` tries each variant **in order**. Schema variants are run
138+
against a **fresh throwaway `Context` (`$dolly`)** that copies only `path`; the
139+
first variant that completes with **no errors** wins, and only then are its
140+
`warnings` merged back into the real context. The dolly does **not** inherit
141+
`skipDefaults`/`isKey`, and even the winning variant's `dynamics` are **not**
142+
merged back — a dynamic parameter nested inside an `anyOf` variant silently loses
143+
its deferred validation. Scalar variants are matched with strict `===`.
144+
145+
Two consequences: **order matters**, and **side effects (including transforms) of
146+
losing variants are discarded** with their dolly context. On total failure, inner
147+
errors (different path) are surfaced if any exist; otherwise a single aggregated
148+
"expects to be A|B|C" error is produced.
149+
150+
`completeDefault` has one extra fork: when the default is itself a `Schema`
151+
(`firstIsDefault()` with a schema variant), it delegates to that schema's
152+
`completeDefault`.
153+
154+
## Deferred validation of dynamic parameters
155+
156+
`Type::complete`, when the completed value is a `DynamicParameter`, does
157+
**not** validate the real type now — it records `[value, expectedType, path]`
158+
(expectedType with the `DynamicParameter|` prefix stripped) in
159+
`Context::dynamics` for **deferred** validation
160+
(DI resolves these once runtime parameters are known). An agent must not "fix" this
161+
by validating dynamics eagerly.
162+
163+
## Merge direction and cast forks (thin)
164+
165+
- **`processMultiple` merges left-value-wins:** each later dataset item is the
166+
`value` (higher priority) merged over the accumulated `base`, so later configs
167+
override earlier ones. Numeric-keyed items append; string-keyed recurse.
168+
- **`Structure::merge` appends numeric keys only when `otherItems` is set**
169+
(`$index = $this->otherItems === null ? null : 0`); `Type::merge` and
170+
`Helpers::merge` always append numeric-keyed items.
171+
- **`castTo` forks by target** (`Helpers::getCastStrategy`): builtin →
172+
`settype`; class **with** constructor → named args from the array/stdClass
173+
(a scalar is passed as a single argument); anything else → property assignment
174+
via `Arrays::toObject((array) $value, new $type)`. There is **no enum branch**:
175+
an enum has no constructor, falls into the `new $type` path and dies with a
176+
PHP `Error`. This fork is the mechanism behind both `castTo(Class::class)`
177+
and Structure's object output.
178+
- **`min`/`max` mean different things by type** (`validateRange`): item count for
179+
arrays, character length (`unicode` type) or byte length (otherwise) for
180+
strings, the value itself for numbers.
181+
182+
## `Expect::from()` mapping rules
183+
184+
`Expect::from($object)` reflects **constructor parameters if `__construct`
185+
exists, otherwise properties** — a class with a constructor has its properties
186+
ignored entirely. Per item: uninitialized property / non-optional parameter →
187+
`required()`; a `null` default on a type that does not accept null → also
188+
`required()` (not "default null"); an **object** default recurses into a nested
189+
`from()`; anything else becomes `default($def)`. The type comes from
190+
`Helpers::getPropertyType` (native type, then `@var`), falling back to `mixed`.
191+
The result is a `Structure` with `castTo($class)` **stacked after** the
192+
constructor's built-in `castTo('object')`, so a completed value travels
193+
array → `stdClass` → instance through the cast fork above.
194+
195+
## Navigation map
196+
197+
| Concern | Where |
198+
|---|---|
199+
| Entry points, phase order | `Processor::process`, `processMultiple` |
200+
| Error accumulation, checker idiom | `Context`, every `Elements/*::complete` |
201+
| `PreventMerging` handling | `Helpers::merge`, `Type`/`Structure`/`AnyOf` normalize/merge/complete |
202+
| Transform/assert/castTo pipeline | `Base` (`transforms`, `doTransform`, `assert`, `castTo`) |
203+
| Type validation & null/dynamic | `Type::complete`, `Helpers::validateType` |
204+
| Structure object output, defaults | `Structure` (`completeDefault`, `validateItems`) |
205+
| Union selection | `AnyOf::findAlternative` |
206+
| Casting strategies | `Helpers::getCastStrategy` |
207+
| DI / integration hook | `Processor::onNewContext`, `createContext` |
208+
| Error message rendering | `Message::toString`, `Message::*` code constants |
209+
| Key schemas, `isKey` | `Type::normalize`/`validateItems`, `Context::isKey` |
210+
| Object-to-schema mapping | `Expect::from`, `Helpers::getPropertyType` |

0 commit comments

Comments
 (0)