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

26 KiB

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. 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 ValidationErrors 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):

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):

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:

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.