PureYAML/docs/rules-swift/validation-rules.md
Mihaela M 5f3892f2c1 chore(rules): vendor the rules-swift canon into docs/rules-swift
Mirror the shared Swift rules canon (rules-swift, commit 4ebb892) into
docs/rules-swift/ as a read-only reference alongside the repo's tailored
docs/rules/. PROVENANCE.md records the source, commit, and what was
included or excluded; the upstream repo's own VCS, CI, and lint tooling
is omitted as machinery rather than rule content.

Exclude the vendored tree from the style gate (scripts/check-style.sh)
and the pre-commit hook: the canon's enforcement docs quote the
forbidden attribution phrases to define them, the same reason the
repo's own enforcement files are already excluded.
2026-06-26 05:36:12 +02:00

442 lines
26 KiB
Markdown

# Validation Rules (MANDATORY)
When validating any parsed model (a config, a document, a manifest, a feature
model, any structured value), you **MUST** follow the **OpenAPIKit validation
idiom** by [Matt Polzin](https://github.com/mattpolzin/OpenAPIKit). This is the
companion to `parsing-rules.md`: parse first, validate second, and write the
validation layer the way OpenAPIKit does. This is not a suggestion. Validations
are composable values, never imperative if-trees.
## Reference repository
**https://github.com/mattpolzin/OpenAPIKit** (local mirror:
`PureYAMLResearch/References/OpenAPIKit`). Study `Sources/OpenAPIKit/Validator/`:
`Validation.swift`, `Validator.swift`, `Validator+Convenience.swift`,
`Validation+Builtins.swift`, `ReferenceValidations.swift`, and the tests under
`Tests/OpenAPIKitTests/Validator/`. Last re-analysed against upstream main
`1d42ea6477` (2026-06-11); when in doubt, the live source wins over this
summary.
## The philosophy (the eleven principles you must honor)
1. **Parse, then validate, as two passes.** The types make many illegal states
unrepresentable; the validator catches only what the type system cannot or
should not enforce. Validation runs over an already parsed, well typed value.
2. **Validations are composable values, not if-trees.** A validation is a plain
struct of closures (a description, a check, an apply-predicate). You combine
them with an algebra (`&&`, `||`, `all`, `lift`, `unwrap`, `lookup`). You
declare what correct looks like; you do not hand write traversal or
accumulation loops.
3. **The description states the CORRECT state, never the failure.** Phrase every
description positively ("Operations contain at least one response"). The
framework derives the negative message by prefixing `"Failed to satisfy: "`.
One source of truth, two readings.
4. **Errors carry a context path.** An error is a reason plus a location (the
coding path). The path is filled in by the traversal and rendered
consistently ("at root of document", "at path: .a.b['c']"). Failures are
locatable without manual bookkeeping.
5. **Application is predicate gated.** Every validation has a `when:` predicate
(default true) that runs before the check, with the full context, so a rule
scopes itself to a subset of values, a coding-path position, or a document
version without conditionals inside the check body.
6. **Dispatch is type directed.** A `Validation<Subject>` applies only to values
of type `Subject`, anywhere they appear in the tree, because the erased
wrapper filters by runtime type. You pick the subject type; the framework
finds every instance.
7. **Default set plus custom extension.** Ship a curated default validator and a
blank one. Builders are fluent and chainable (`.validating(...)`,
`.withoutValidating(...)`), returning Self. Users compose policy from defaults
plus or minus their own rules. Builtins live as `public static var` members of
a dedicated caseless namespace enum (`BuiltinValidation`), referenceable by
KeyPath rooted at that namespace so they can be added or removed by identity,
and the identity key IS the description string (`withoutValidating` removes by
matching descriptions across every list), so descriptions must be unique and
stable. Where strictness comes in grades, model the grades as whole-set
replacement methods on the validator (OpenAPIKit's lenient reference checks
by default, `validatingAllReferencesFoundInComponents()` swapping in the
strict set, `skippingReferenceValidations()` clearing it), not as flags
threaded through individual rules.
8. **Single-error and multi-error forms.** The Bool form (frontload the
description, return false if invalid) is for a rule that fails one way and auto
formats the message. The array-of-errors form is for a rule that can fail in
several places at once (one error per missing variable, per undefined
reference), each with its own reason and path. Choose by whether a single value
can fail in more than one way.
9. **Two error channels with a strict switch.** Validation errors always throw;
parse warnings are separate and elevated to errors only under `strict: true`
(the default), otherwise returned for inspection. Keep "spec violation" and
"lossy but parseable" distinct.
10. **One framework, parameterized over the document.** When more than one layer
validates (an authoring linter over a hand-written manifest, a semantic
validator over a resolved model), do NOT copy the machinery per layer.
OpenAPIKit fixes the document type to `OpenAPI.Document`; you generalize it.
Factor the error type, `Validation`, the erased wrapper, the `Validator`, and
the combinators into one framework in the lowest layer, parameterized over both
the `Subject` and the `Document` (the cross-cutting context: a library, a scope,
or `Void` when a rule needs nothing beyond its subject). Each layer then defines
only its own document, its rules, and its traversal. Two copies of
`ValidationError` is the same duplication this rule exists to prevent, one level
up.
11. **Each rule lives in exactly one authoritative layer; lower layers do not
re-implement it.** A rule belongs to the layer that holds the data it judges. A
*semantic* rule, one that needs a resolved model (referential closure, scope,
inheritance, readiness), lives in the semantic layer and nowhere else. A lower
layer, a syntax or source-structural validator, checks only what is meaningful at
its own level (an empty identifier, a duplicate authored name) and MUST NOT copy
the semantic rules down, even when it could approximate them. The reason is the
dependency arrow: if the semantic layer depends on the syntax layer (parse then
resolve), the syntax layer cannot call back into the semantic one without a cycle,
so the temptation is to re-derive the semantic rule in the lower layer, and that
is the duplication to refuse. The correct direction is the other way: the
authoritative (higher) layer *consumes* the lower one (it parses, then validates),
so the full "source to semantic verdict" path lives at the top, not split into two
drifting rule sets. When you find the same check in two layers, delete the copy in
the layer that does not own the data, and document the boundary on both sides.
## The core types (build these, with these exact roles)
- **`ValidationContext<Subject>`**: the read-only bundle every check receives.
Carries `document` (the whole structure, for cross-cutting checks), `subject`
(the current value of the specialized type), and `codingPath` (where in the
tree). Adapt `document` to your root model name.
- **`Validation<Subject>`**: the atomic value. Stores `description: String`, a
multi-error `validate: (ValidationContext<Subject>) -> [ValidationError]`, and a
`predicate: (ValidationContext<Subject>) -> Bool`. Has `apply(to:at:in:)` that
gates on the predicate then runs validate. Two inits:
- multi-error: `init(description: String? = nil, check: @escaping (Context) -> [ValidationError], when: @escaping (Context) -> Bool = { _ in true })`.
- single-error Bool: `init(description: String, check: @escaping (Context) -> Bool, when: ... = { _ in true })`, where false yields one error `"Failed to satisfy: \(description)"`. Description is REQUIRED here because it is the message.
- **`ValidationError`**: `reason: String` plus `codingPath: [CodingKey]`. Its
`description` strips a trailing period from the reason, renders an empty path as
`"<reason> at root of document"` and a non-empty path as
`"<reason> at path: <path>"`. Match this formatting.
- **`ValidationErrorCollection`**: the one value thrown at the end, with `.values:
[ValidationError]`. Tests inspect `.values`.
- **`Validatable`**: a pure marker protocol constraining `Subject`. Conform your
model types (and the primitives/containers you traverse) to it.
- **`AnyValidation`** (type erasure, internal): wraps `Validation<T>` so a
heterogeneous list lives together, and filters by runtime type. It MUST guard
against an optional matching its wrapped type (a `T?` must not satisfy a
`Validation<T>`): cast with `as? T`, then also check `type(of: subject) == type(of: input)`.
- **`Validator`**: a `final class` (reference semantics; the fluent builders
mutate self and return it, `@discardableResult`). Holds the default rules in
TWO tiers (`nonReferenceDefaultValidations` + `referenceDefaultValidations`)
plus `customValidations`; the effective order is fixed and tested:
non-reference defaults, then reference defaults, then custom. Exposes `init()`
(defaults on), a `blank` with none, fluent `validating(...)` /
`withoutValidating(...)`, and a `validationDescriptions` accessor listing the
active rules. The builtin add/remove overloads take VARIADIC KeyPaths via
parameter packs (`validating<each T: Encodable>(_ validations: repeat
KeyPath<BuiltinValidation.Type, Validation<each T>>)`), so one call can mix
builtins specialised on different subject types.
- **`BuiltinValidation`**: the caseless namespace enum carrying every shipped
rule as a `public static var ...: Validation<Subject>`. Rule FAMILIES that
differ only by type are generated by an internal factory (OpenAPIKit's
`References.referencesAreValid(ofType:named:mustBeInternal:mustPointToComponents:)`
computes graded descriptions and checks per component type) rather than
copy-pasted per type.
## How validation runs
Traverse the parsed model and offer every value to every validation; the type
filter in the erased wrapper makes each `Validation<T>` fire only on `T` values,
at every path where one occurs. OpenAPIKit does this by walking an `Encodable`
through a fake `Encoder` that, at each node, applies validations and extends the
coding path by the key or index. Dictionary keys are themselves validated: the
keyed container's child encoder recovers the original key value (`AnyCodingKey
.originalValue as? Validatable`) and offers it to the validations too. You may
reuse that Encoder trick when your model is `Encodable`, or write an explicit
recursive walk that builds the path and dispatches by subject type. Either way:
predicate gates before check, errors accumulate with their path, and the whole
run throws a single `ValidationErrorCollection` if non-empty.
The warning channel rides the same traversal: types declare their parse-time
warnings via small protocols (`HasWarnings`, and `HasConditionalWarnings` for
warnings that depend on the whole document), the walker collects them at each
node and contextualises each with the coding path where it was found. The
entrypoint is `validate(using:strict:) -> [Warning]` (`@discardableResult`):
under `strict: true` (the default) warnings are converted into
`ValidationError`s and merged into the thrown collection; under `strict: false`
they are returned for inspection instead.
## Authoring a validation (every surface)
- **Multi-error init**: return your own `[ValidationError]`, one per problem, each
with a custom reason and `context.codingPath`. Use when a value can fail several
ways at once.
- **Single-error Bool init**: pass a positive `description` and a Bool check;
failure auto-produces `"Failed to satisfy: <description>"`. The dominant form.
- **`.validating(...)` on the Validator**: add a rule inline without constructing
a `Validation` (closure form, Bool form, prebuilt form, and a KeyPath form that
adds named builtins). Mirror these overloads.
- **`lift(\.child, into: validationsOnChild...)`**: run child-typed validations
against a lifted value while keeping the parent's path and document. The key
compositional move.
Real exemplar (single-error, KeyPath check):
```swift
public static var operationsContainResponses: Validation<Operation> {
.init(
description: "Operations contain at least one response",
check: \.responses.count > 0
)
}
```
Real exemplar (multi-error, one error per offender):
```swift
public static var serverVariablesAreDefined: Validation<Server> {
.init(
description: "All server template variables are defined",
check: { context in
context.subject.urlTemplate.variables
.filter { !context.subject.variables.contains(key: $0) }
.map { name in
ValidationError(
reason: "Server Object does not define the variable '\(name)' ...",
at: context.codingPath
)
}
}
)
}
```
## The combinator algebra (the declarative core)
Provide these as free functions returning closures, so they compose by value:
- **`&&` / `||`** on error-array checks (`&&` concatenates both error lists; `||`
short-circuits, errors only if both fail) and on Bool predicates (the obvious
way).
- **`==`, `!=`, `>`, `>=`, `<`, `<=`** lifting a `KeyPath<ValidationContext<T>, U>`
or a `KeyPath<T, U>` (rooted through `context.subject`) and a literal into a
predicate. The dual rooting lets an author write `\.subject.foo`, `\.document.bar`,
or `\SomeType.foo` interchangeably and thereby tell the type system which type the
validation specializes on.
- **`take(\.path) { value in Bool }`**: dig to a value and run arbitrary logic
beyond equality.
- **`lift(\.child, into:)`**: as above.
- **`unwrap(\.optional, into:description:)`**: unwrap an optional, error if nil,
else run child validations.
- **`lookup` / `unwrapAndLookup`**: resolve a reference against the document's
component store, error if not found, else validate the resolved value.
- **`all(validations...)`**: apply many `Validation<T>` to the same context
(equivalent to `lift` with `\.self`).
Usage reads declaratively: `check: \.responseOutcomes.count >= 1 && { $0.subject.responseOutcomes.allSatisfy { $0.status == 200 } }`,
or `check: take(\.tags) { tags in tags.map(\.name).count == tags?.count }`.
One type-inference contract to honour between the two clauses: the subject type
must be pinned in at least one of `check:` / `when:`. When one clause only
touches `\.document` or `\.codingPath` (which say nothing about the subject),
the other clause must name the subject type, for example via a fully rooted
KeyPath (`take(\OpenAPI.Document.servers) { ... }`), so the compiler knows what
the validation specialises on. Both `take` and every comparison operator come
in two rootings for exactly this reason (`KeyPath<Subject, U>` and
`KeyPath<ValidationContext<Subject>, U>`).
## The test style (MANDATORY, copy it exactly)
Tests mirror the philosophy. The recipe:
1. **Build the subject** (the document / model), seeding the values under test.
2. **Build a validator**: `Validator.blank.validating(...)` to test ONE rule in
isolation, `Validator()` to test the default set, or `Validator().validating(...)`
for a custom rule on top of defaults.
3. **Run**: `try model.validate(using: validator)` (success tests simply run; a
throw fails the test).
4. **Assert failures** with `XCTAssertThrowsError`, cast to
`ValidationErrorCollection`, then check `.values.count`, each
`.values[i].reason` (including the `"Failed to satisfy: "` prefix), and
`.codingPath.map { $0.stringValue }`. For message rendering, assert
`String(describing: error)` equals `"<reason> at path: <path>"`.
5. **Isolate the combinators**: test each operator/`lift`/`take` as a pure
function against a hand-built dummy context, asserting `.isEmpty` / `.count` of
the resulting closure, with no full traversal. Test the erased wrapper directly
(`apply(to:at:in:)`), including that an optional and a wrong type both yield no
errors, and that the same value of the same type twice yields two errors.
Representative failure test to copy:
```swift
func test_unconditionalServerCountCheckFails() {
let document = ... // seed two server arrays
let validator = Validator.blank
.validating("All server arrays have more than 1 server", check: \[Server].count > 1)
XCTAssertThrowsError(try document.validate(using: validator)) { error in
let error = error as? ValidationErrorCollection
XCTAssertEqual(error?.values.count, 1)
XCTAssertEqual(error?.values.first?.reason, "Failed to satisfy: All server arrays have more than 1 server")
XCTAssertEqual(error?.values.first?.codingPath.map { $0.stringValue }, ["paths", "/hello/world", "get", "servers"])
}
}
```
(Use Swift Testing `#expect` where the project uses Swift Testing, but keep the
same shape: run the validator, assert the collection's count, each reason, each
path.)
## Exhaustive testing (MANDATORY, the upstream bar)
The recipe above applied to a SAMPLE of rules does not satisfy this rule. The
upstream suite is exhaustive in five specific ways, and a conforming repo
reproduces all five:
1. **Every rule ships a failing AND a succeeding test**, the upstream
`test_X_fails` / `test_X_succeeds` pairing (BuiltinValidationTests covers
every builtin both ways). The succeeding fixture is a NEAR MISS, the boundary
the rule almost trips (the value at the exact limit, the predicate one step
from firing), not merely an unrelated clean document. A rule with only its
failing direction tested is untested: nothing proves it stays quiet on valid
input.
2. **A configuration-pin test** asserts the EXACT ordered
`validationDescriptions` list (and count) of every validator configuration:
`blank` is empty, each fluent variant lists precisely its rules, the full
default set is pinned description by description (upstream
`test_variousConfigurationsHaveExpectedValidationCounts`). Adding, removing,
or rewording a rule MUST fail a test before the change is deliberate.
3. **The machinery has its own negative tests** (upstream ValidationTests):
the erased wrapper applied to an Optional subject yields no errors; applied
to a wrong type yields no errors; a false `when` predicate yields no errors;
a conditional rule gets a dedicated does-not-run test
(`test_validationNeverRunsAndSucceeds`); and the same value of the same type
occurring twice yields two errors (the positive control proving the
negatives are not vacuous).
4. **Many-error documents assert the COMPLETE error list**: exact
`.values.count`, then every reason and every coding path, in traversal order
(upstream asserts all nine reference errors with all nine paths in one
test). `contains` on a single element hides regressions in every other
error and in the ordering.
5. **Coverage is itself a tested law.** Keep a registry of every finding the
default set can produce (stable codes, or the descriptions list where codes
do not exist) and a meta-test that one failing fixture and one near-miss
fixture exist per entry, asserting set equality and naming any gap in the
failure message. Plant a fake entry once to prove the meta-test fails, then
remove it. Upstream pins this via the exact descriptions list; a registry
plus meta-test is the equivalent for code-bearing findings.
## Full validation coverage (MANDATORY)
The exhaustive-testing section proves every *rule* is tested. This proves the
rule *set* is complete over the input model: no field goes unexamined. The
OpenAPIKit idiom validates the whole document structure, not a chosen subset; a
conforming repo does the same.
1. **Every field is modeled or reported, never silently ignored.** For each
field or construct the input can carry, the parsed model either handles it (it
has a typed home and the validations that constrain it) or the validator emits
an explicit "unsupported" / "ignored, with reason" finding. A field that is
neither handled nor reported is a silent gap and a violation, even when the
output looks right. This is the `…FieldsAreModeledOrReported` discipline: layer,
transform, shape, style, mask, matte, and silent-risk fields each get a rule
that accounts for every key.
2. **Unknown and unexpected fields are detected, not dropped.** A rule asserts
that every key present is either known (modeled) or explicitly permitted
metadata, and reports an unrecognized key with its path. The parser must not
`try?`-swallow or otherwise discard a field the validator should have seen
(see `core/no-shortcuts-first-principles.md`: silent drop is forbidden).
3. **Behavior-affecting fields get priority and a silent-risk rule.** Any field
that can change output but is easy to miss (a compositing mode, a matte source,
a time remap) carries a dedicated rule so its presence is never accepted
unhandled.
4. **Coverage over the model is a tested law.** Keep a registry of every field or
construct the input model defines, and a meta-test asserting each maps to a
validation or an explicit unsupported/ignored classification. Adding a field to
the model without classifying it MUST fail that meta-test. This is the
input-side twin of the finding-coverage meta-test above: that one proves every
rule is exercised; this one proves every field has a rule.
A validator can pass every test it ships and still be incomplete if a field of
the input was never given a rule. Full coverage closes that gap: the validation
set is complete with respect to the input model, and incompleteness is caught
mechanically, not by review.
## Every public type is validated or excluded (MANDATORY)
This is the part it is most important to stick to. Validation is not applied to a
chosen subset of types. It is applied to EVERY public type. A package's public
surface is the set of values other code can construct and hand in, and each one
is a place malformed or untrusted state can enter, so each public type is exactly
one of two things:
- **validated**: it has at least one `Validation<Subject>` that constrains it, or
- **excluded**: it is listed in a checked-in exclusions file with a one-line
reason (a pure data-transfer type with no invariants, or a type whose
invariants are already made unrepresentable by its initializer).
There is no third option. A public type that is neither validated nor excluded
with a reason is a silent gap, and a silent gap is a violation even when every
shipped test passes.
**Enforce it mechanically, per repo, not by review.** Ship a coverage gate (a
script run by the pre-commit hook and in CI) that: (1) enumerates every public
type in the package, (2) checks each against the validator's subject types and
the exclusions file, and (3) fails the build, naming the type, if any public type
is neither covered nor excluded. Adding a new public type without a validation or
an exclusion entry MUST fail the gate. That is what "stick to it" means: the
discipline is enforced by the build, every time, for every public type.
**Prefer make-unrepresentable over validate.** The strongest coverage is deleting
the rule. If a type can carry its own invariant (a non-empty collection type, a
smart constructor that rejects the bad value at init), the bad state cannot be
constructed and needs no runtime validation. Excluding such a type because its
invariants are unrepresentable is the win, not a loophole. Reach for a
`Validation` only when the type system cannot or should not carry the invariant.
## DO
- Separate parsing from validation; validate an already typed value.
- Write each rule as a `Validation<Subject>` value with a positive description.
- Build errors as reason plus context path; let the traversal fill the path.
- Use the single-error Bool form by default; the multi-error form when a value can
fail several ways at once.
- Gate scope with the `when:` predicate, not with `if` inside the check.
- Compose with the operator algebra (`&&`, `lift`, `take`, `unwrap`, `lookup`,
`all`) instead of nesting conditionals.
- Ship a default validator plus a blank one; make rules addable and removable by
identity.
- In tests, isolate one rule with `.blank.validating(...)`, assert the error
collection's count, reasons, and coding paths, and unit test the combinators as
pure functions.
- When a second layer needs validation, reuse one framework parameterized over the
document (principle 10); never copy the error type, `Validation`, or `Validator`.
## DON'T
- Do not fold validation into parsing.
- Do not write a monolithic `validate()` that appends to a mutable error array
through a tree of `if` statements. That is the anti-pattern this rule exists to
replace.
- Do not phrase descriptions as failures; phrase the correct state.
- Do not drop the location; every error carries its path.
- Do not let an optional satisfy a non-optional validation (guard the erasure).
## Acceptance check
A repo whose validation conforms to this rule satisfies all of: (1) validations
are declared as `Validation<Subject>` values (grep finds the type, not a single
imperative `func validate()` accumulating into a `var errors`); (2) descriptions
read as the correct state, are unique (they are the removal identity), and
failures render `"Failed to satisfy: ..."`; (3) errors carry a coding path and
render `"... at path: ..."`; (4) a `Validator` with a default set and a `blank`,
plus fluent `validating` / `withoutValidating`; (5) tests build the subject, run
`validate(using:)`, and assert the thrown collection's `.values` count, reasons,
and coding paths, with the combinators unit tested as pure functions; (6) every
public entry point that consumes untrusted input gates on the validator before
doing work, and a non-throwing recovery accessor (a `validationOutcome` style
API returning valid-or-errors) exists beside the throwing gate for callers that
inspect rather than abort; (7) the test suite is exhaustive per the section
above: a failing plus near-miss succeeding pair for every rule, a
configuration-pin test over exact validation descriptions, machinery negatives
(optional, wrong type, false predicate, does-not-run), complete error-list
assertions on many-error documents, and a coverage meta-test (or descriptions
pin) that names any rule shipped without its fixtures; (8) the validation set is
complete over the input model per the full-coverage section: every field is
modeled or explicitly reported, unknown keys are detected, and a coverage
meta-test fails if any field of the model lacks a rule. A validator that is one
big if-tree fails this rule even if it produces correct results, and so does a
validator that quietly ignores fields the input can carry.