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.
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)
-
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.
-
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. -
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. -
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.
-
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. -
Dispatch is type directed. A
Validation<Subject>applies only to values of typeSubject, anywhere they appear in the tree, because the erased wrapper filters by runtime type. You pick the subject type; the framework finds every instance. -
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 aspublic static varmembers 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 (withoutValidatingremoves 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. -
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.
-
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. -
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, theValidator, and the combinators into one framework in the lowest layer, parameterized over both theSubjectand theDocument(the cross-cutting context: a library, a scope, orVoidwhen a rule needs nothing beyond its subject). Each layer then defines only its own document, its rules, and its traversal. Two copies ofValidationErroris the same duplication this rule exists to prevent, one level up. -
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. Carriesdocument(the whole structure, for cross-cutting checks),subject(the current value of the specialized type), andcodingPath(where in the tree). Adaptdocumentto your root model name.Validation<Subject>: the atomic value. Storesdescription: String, a multi-errorvalidate: (ValidationContext<Subject>) -> [ValidationError], and apredicate: (ValidationContext<Subject>) -> Bool. Hasapply(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.
- multi-error:
ValidationError:reason: StringpluscodingPath: [CodingKey]. Itsdescriptionstrips 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 constrainingSubject. Conform your model types (and the primitives/containers you traverse) to it.AnyValidation(type erasure, internal): wrapsValidation<T>so a heterogeneous list lives together, and filters by runtime type. It MUST guard against an optional matching its wrapped type (aT?must not satisfy aValidation<T>): cast withas? T, then also checktype(of: subject) == type(of: input).Validator: afinal class(reference semantics; the fluent builders mutate self and return it,@discardableResult). Holds the default rules in TWO tiers (nonReferenceDefaultValidations+referenceDefaultValidations) pluscustomValidations; the effective order is fixed and tested: non-reference defaults, then reference defaults, then custom. Exposesinit()(defaults on), ablankwith none, fluentvalidating(...)/withoutValidating(...), and avalidationDescriptionsaccessor 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 apublic static var ...: Validation<Subject>. Rule FAMILIES that differ only by type are generated by an internal factory (OpenAPIKit'sReferences.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 andcontext.codingPath. Use when a value can fail several ways at once. - Single-error Bool init: pass a positive
descriptionand a Bool check; failure auto-produces"Failed to satisfy: <description>". The dominant form. .validating(...)on the Validator: add a rule inline without constructing aValidation(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 aKeyPath<ValidationContext<T>, U>or aKeyPath<T, U>(rooted throughcontext.subject) and a literal into a predicate. The dual rooting lets an author write\.subject.foo,\.document.bar, or\SomeType.foointerchangeably 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 manyValidation<T>to the same context (equivalent toliftwith\.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:
- Build the subject (the document / model), seeding the values under test.
- Build a validator:
Validator.blank.validating(...)to test ONE rule in isolation,Validator()to test the default set, orValidator().validating(...)for a custom rule on top of defaults. - Run:
try model.validate(using: validator)(success tests simply run; a throw fails the test). - Assert failures with
XCTAssertThrowsError, cast toValidationErrorCollection, then check.values.count, each.values[i].reason(including the"Failed to satisfy: "prefix), and.codingPath.map { $0.stringValue }. For message rendering, assertString(describing: error)equals"<reason> at path: <path>". - Isolate the combinators: test each operator/
lift/takeas a pure function against a hand-built dummy context, asserting.isEmpty/.countof 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:
- Every rule ships a failing AND a succeeding test, the upstream
test_X_fails/test_X_succeedspairing (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. - A configuration-pin test asserts the EXACT ordered
validationDescriptionslist (and count) of every validator configuration:blankis empty, each fluent variant lists precisely its rules, the full default set is pinned description by description (upstreamtest_variousConfigurationsHaveExpectedValidationCounts). Adding, removing, or rewording a rule MUST fail a test before the change is deliberate. - 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
whenpredicate 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). - 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).containson a single element hides regressions in every other error and in the ordering. - 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.
- 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
…FieldsAreModeledOrReporteddiscipline: layer, transform, shape, style, mask, matte, and silent-risk fields each get a rule that accounts for every key. - 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 (seecore/no-shortcuts-first-principles.md: silent drop is forbidden). - 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.
- 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 withifinside 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, orValidator.
DON'T
- Do not fold validation into parsing.
- Do not write a monolithic
validate()that appends to a mutable error array through a tree ofifstatements. 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.