ITADN
Joseph-Cursio/SwiftPropertyLaws
Joseph-Cursio/SwiftPropertyLaws · 文件
文件最后提交记录最后更新时间
README.md

SwiftPropertyLaws

Property-based protocol law checks for Swift's standard-library protocols. Catches semantic conformance bugs the compiler can't.

This is an experiment in property-based testing. I selected Swift's protocols to start because many have clearly identifiable properties.

Status: v1.3.0 released — PRD §5.7 Strategy 3 memberwise-Arbitrary generator derivation shipped on top of v1.2's collection-refinements cluster and v1.1's round-trip cluster. 308 tests passing on Swift 6.3, macOS 14+. See Status for what's stable.

The problem

Swift's compiler enforces the structural contract of a protocol — methods exist with correct signatures. It does not and cannot enforce the behavioral contract. All three of these compile cleanly:

// Violates Equatable.symmetry: x == y differs from y == x
extension MyType: Equatable {
    static func == (lhs: MyType, rhs: MyType) -> Bool {
        return lhs.priority > rhs.priority
    }
}

// Violates Hashable: equal values produce different hashes
extension MyType: Hashable {
    func hash(into hasher: inout Hasher) {
        hasher.combine(UUID())  // breaks Dictionary, Set
    }
}

// Violates Codable round-trip fidelity
extension MyType: Codable {
    // encode omits a field; decode provides a default
    // decode(encode(x)) ≠ x for non-default values
}

Each is a real production bug class. None is caught by swift build.

What it covers

ProtocolLaws
Equatablereflexivity, symmetry, transitivity, negation consistency
Hashablehash/equality consistency, stability within a process, distribution
Comparableantisymmetry, transitivity, totality, operator consistency
Strideabledistance round-trip, advance round-trip, zero-advance identity, self-distance is zero
Codableround-trip fidelity (.strict / .semantic / .partial modes)
RawRepresentableT(rawValue: x.rawValue) == x round-trip
LosslessStringConvertibleT(String(describing: x)) == x round-trip
Identifiableid stability within a process
CaseIterableexactly-once enumeration
IteratorProtocoltermination stability, single-pass yield
SequenceunderestimatedCount lower bound, multi-pass consistency, makeIterator() independence
Collectioncount consistency, index validity, non-mutation
BidirectionalCollectionindex(before:)/index(after:) round-trips both ways, reverse-traversal consistency
RandomAccessCollectiondistance consistency, offset consistency, negative-offset inversion
MutableCollectionswapAt swaps values, swapAt involution
RangeReplaceableCollectionempty-init is empty, remove-at/insert round-trip, removeAll() makes empty, replaceSubrange applies edit
SetAlgebraunion/intersection idempotence + commutativity, empty identity
AdditiveArithmeticaddition associativity + commutativity, zero identity, subtraction inverse, self-subtraction is zero
Numericmultiplication associativity + commutativity, multiplicative identity, zero annihilation, left/right distributivity
SignedNumericnegation involution, additive inverse, negation distributes over addition, negate-mutation consistency
BinaryIntegerdivision/multiplication round-trip, remainder bound, self/by-one division, quotient-remainder consistency, bitwise AND/OR/XOR idempotence + commutativity + identity, double-negation, AND distributes over OR, De Morgan, shift-by-zero identity, trailing-zero bit count range
SignedIntegersignum consistency
UnsignedIntegernon-negative, magnitude is self
FixedWidthIntegerbit-width matches type, four reportingOverflow consistency laws, wrapping arithmetic does not trap, min/max bounds reachable, byteSwapped involution, nonzero bit count range
FloatingPointinfinity is infinite, signed-zero equality, additive inverse on finite, next-up/down round-trip, sign matches less-than-zero, absolute value non-negative, plus 5 NaN-domain laws gated by LawCheckOptions.allowNaN
BinaryFloatingPointradix-2 constraint, significand/exponent reconstruction, binade membership, integer-conversion exactness
StringProtocolString-init round-trip, count match across String conversion, isEmpty / count-zero consistency, hasPrefix / hasSuffix empty, lowercased / uppercased idempotence, UTF-8 view invariance

Inheritance is implicit: checkComparable… runs Equatable's laws automatically, checkStrideable… runs Comparable's (and transitively Equatable's), checkCollection… runs Sequence's and IteratorProtocol's, checkRandomAccessCollection… runs the whole BidirectionalCollection → Collection → Sequence → IteratorProtocol chain, and the algebraic chain checkSignedNumeric…checkNumeric…checkAdditiveArithmetic… runs in linear order. PRD §4.3 is the spec.

Installation

// Package.swift
.package(url: "https://github.com/Joseph-Cursio/SwiftPropertyLaws.git", from: "1.0.0")
.target(
    name: "MyApp",
    dependencies: [
        .product(name: "PropertyLawKit", package: "SwiftPropertyLaws"),
        // Optional — for the @PropertyLawSuite macro:
        .product(name: "PropertyLawMacro", package: "SwiftPropertyLaws"),
    ]
),

Requires Swift 6.1+ tools, macOS 14+ at runtime.

Three ways to use it

1. Manual call

The simplest entry point — pass a generator, get back per-law CheckResults.

import Testing
import PropertyBased
import PropertyLawKit

@Test func myTypeLaws() async throws {
    try await checkHashablePropertyLaws(
        for: MyType.self,
        using: Gen.myType()
    )
}

Throws PropertyLawViolation on Strict-tier failures with a replayable seed and counterexample.

2. @PropertyLawSuite peer macro

Apply to a type. The macro reads the type's inheritance clause and emits a peer test struct with one @Test func per recognized stdlib conformance.

import PropertyLawMacro

@PropertyLawSuite
struct MyType: Equatable, Hashable, Codable {
    let id: Int
    let name: String
}

extension MyType {
    static func gen() -> Generator<MyType, some SendableSequenceType> {
        zip(Gen<Int>.int(in: 0...100), Gen<Character>.letterOrNumber.string(of: 1...8))
            .map { MyType(id: $0, name: $1) }
    }
}

Expands at compile time to:

struct MyTypePropertyLawTests {
    @Test func hashable_MyType() async throws { /* ... */ }
    @Test func codable_MyType() async throws { /* ... */ }
}

Most-specific-conformance dedupe runs at expansion time — Hashable subsumes Equatable, etc., so you get one call per protocol.

Generator derivation (M3). For CaseIterable enums and RawRepresentable enums backed by recognized stdlib raw types, the macro derives the generator automatically — no gen() method required.

@PropertyLawSuite
enum Status: CaseIterable, Equatable {
    case pending, active, archived
}
// Macro emits: using: Gen<Status>.element(of: Status.allCases)

@PropertyLawSuite
enum Direction: String, Codable, Equatable {
    case north, south, east, west
}
// Macro emits: using: Gen<Character>.letterOrNumber.string(of: 0...8)
//                       .compactMap { Direction(rawValue: $0) }

For other types, the macro falls through to <TypeName>.gen() (define it yourself) and warns at compile time explaining what's needed. Memberwise-Arbitrary derivation for plain structs is on the roadmap but not in M3.

3. Whole-module discovery (Swift Package Plugin)

For projects with many types, run the plugin and commit the generated file.

swift package --allow-writing-to-package-directory propertylawcheck discover --target MyModule

Walks every .swift file in the target, aggregates type declarations and extensions across files, and emits Tests/MyModuleTests/PropertyLawTests.generated.swift with one @Suite struct per recognized type.

Idempotent: re-running with no source changes produces byte-identical output. Suppression markers in the generated file (// property-law-suppress: <protocol>_<TypeName>) survive regeneration — the user marks a check as deliberately skipped, the next run keeps it skipped.

Strictness tiers

Not every law is universally true in idiomatic Swift. Hashable allows hash collisions; Comparable on Float/Double fails for NaN; Codable round-trips are intentionally lossy in many real schemas. The kit classifies every law:

TierDefault behavior on violation
StrictTest fails. Reflexivity, symmetry, transitivity, count consistency, etc.
ConventionalReported as failed, but doesn't throw under EnforcementMode.default. Pass .strict to escalate.
HeuristicInformational only. Never fails. Distribution sanity, etc.

PRD §4.2 has the full tier-per-law table.

Suppressions

When a law-check legitimately doesn't apply (NaN reflexivity on a Float-bearing type, intentional Codable lossiness, etc.) suppress at the call site:

try await checkEquatablePropertyLaws(
    for: MyType.self,
    using: Gen.myType(),
    options: LawCheckOptions(
        suppressions: [
            .skip(.equatable(.reflexivity), reason: "NaN by design")
        ]
    )
)

Two kinds:

  • .skip — don't run the check; record .suppressed(reason:) in the result with trials: 0.
  • .intentionalViolation — run the check; if it would fail, record .expectedViolation(reason:counterexample:) instead of .failed.

Suppressions never throw, regardless of EnforcementMode. They appear in the test report so reviewers see policy drift.

Confidence reporting

CheckResult carries replayable provenance: seed, environment fingerprint (Swift version + backend identity), trial count, near-miss list (when applicable), coverage hints (opt-in via CoverageClassifier).

Replay-validation is opt-in: pass an expectedReplayEnvironment and the kit refuses to run if the live environment diverges, so a CI artifact stored months earlier doesn't silently re-roll a different test under the same seed string.

Status

ComponentStatus
PropertyLawKit (PRD Contribution 1)v1.0 base + v1.1 round-trip + v1.2 collection-refinements + v1.4 numeric/integer/FloatingPoint + v1.5 StringProtocol shipped — closes out the entire PRD §4.3 v1.1+ candidates list
PropertyLawMacro peer macro (PRD §5.3 Macro Mode)M1 shipped
swift package propertylawcheck discovery plugin (PRD §5.3 Discovery Mode)M2 shipped
Generator derivation (PRD §5.7) — CaseIterable + RawRepresentable enumsM3 shipped
Memberwise-Arbitrary derivation (PRD §5.7 Strategy 3)Shipped — structs whose every stored property is a recognized stdlib raw type (Int / String / Bool / Double / Float and the fixed-width integer family) get zip(...).map { Type(prop: $0.N, …) } derived through the synthesized memberwise initializer; arity 1–10 (swift-property-based's zip overload cap); falls through to .todo for non-raw member types, structs declaring user init, and class/actor kinds
Advisory: missing-conformance suggestions (PRD §5.4)M4 shipped — opt-in via --advisory, HIGH-confidence detectors for Equatable, Hashable, Comparable, Codable
Advisory: cross-function round-trip discovery (PRD §5.5)M5 shipped — opt-in via --advisory, syntactic pair detector matching curated naming pairs (encode/decode, serialize/deserialize, push/pop, etc.) and signature inversion across same-type member functions + module-level free functions; @Discoverable(group:) peer macro promotes group-tagged pairs to HIGH confidence even without a curated naming match
Experimental layer (pattern warnings, Codable-derived generators)Not started
1.0 External validation gate (PRD v0.3 §8 — three-pass)All three passes shipped: Pass 1 (discovery scan ≥4 packages), Pass 2 (composition with swift-argument-parser), Pass 3 (git-archaeology, results in Validation/FINDINGS.md)

The PropertyBackend abstraction (PRD §4.5) is shipped public with SwiftPropertyBasedBackend as the single implementation. swift-property-based is the only backend v1 ships; the abstraction stays open for future alternatives but the kit doesn't chase parity for its own sake.

Documentation

Build & test

swift package clean && swift test
swiftlint lint

Both should be silent on a clean checkout.

License

MIT — see LICENSE.