From 611c079255be0e478b64f306af93abdba9bfed01 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Fri, 5 Jun 2026 12:33:09 +0200 Subject: [PATCH] test(coding): add sequence fixtures and contract gate --- .github/workflows/ci.yml | 1 + AGENTS.md | 1 + CHANGELOG.md | 2 + Tests/UnkeyedCodingFixtures.swift | 166 ++++++++++++++++++++++++++++ Tests/UnkeyedCodingTests.swift | 23 ++++ Tests/UnkeyedEncodingFixtures.swift | 148 +++++++++++++++++++++++++ Tests/UnkeyedFailureFixtures.swift | 137 +++++++++++++++++++++++ docs/CONVENTIONS.md | 1 + docs/rules/verification.md | 2 + scripts/check-all.sh | 1 + scripts/check-forbidden-patterns.sh | 85 ++++++++++++++ scripts/check-namespacing.sh | 12 ++ 12 files changed, 579 insertions(+) create mode 100644 Tests/UnkeyedCodingFixtures.swift create mode 100644 Tests/UnkeyedEncodingFixtures.swift create mode 100644 Tests/UnkeyedFailureFixtures.swift create mode 100644 scripts/check-forbidden-patterns.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a061c69..05923af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,7 @@ jobs: run: | bash scripts/check-style.sh bash scripts/check-namespacing.sh + bash scripts/check-forbidden-patterns.sh git fetch origin main --depth=1 || true BASE_REF=origin/main bash scripts/check-changelog-touched.sh - name: Format and lint diff --git a/AGENTS.md b/AGENTS.md index 810cba1..b8d6484 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ Before claiming a change is complete, run and cite: ```sh bash scripts/check-style.sh bash scripts/check-namespacing.sh +bash scripts/check-forbidden-patterns.sh bash scripts/check-changelog-touched.sh swiftformat . --config .swiftformat --lint swiftlint --config .swiftlint.yml --strict diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f666f..23c68aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 deterministically. - Add unkeyed typed Decodable and Encodable conversion for sequences, including nested sequences and sequence properties on keyed structs. +- Add a fixture-backed sequence typed-conversion test corpus and a + forbidden-pattern verification gate for package, source, and test contracts. ### Changed diff --git a/Tests/UnkeyedCodingFixtures.swift b/Tests/UnkeyedCodingFixtures.swift new file mode 100644 index 0000000..6a2bdbe --- /dev/null +++ b/Tests/UnkeyedCodingFixtures.swift @@ -0,0 +1,166 @@ +@testable import PureYAML +import Testing + +struct SequenceArticle: Codable, Equatable { + var title: String + var tags: [String] + var ranks: [Int?] +} + +struct SequenceCatalog: Codable, Equatable { + var articles: [SequenceArticle] + var featured: [String?] + var empty: [Int] +} + +let sequenceArticle = SequenceArticle( + title: "Typed YAML", + tags: ["Swift", "YAML"], + ranks: [1, nil, 3], +) + +let sequenceArticleValue = PureYAML.Model.Value.mapping(.init([ + .init(key: "title", value: .string("Typed YAML")), + .init(key: "tags", value: .sequence([ + .string("Swift"), + .string("YAML"), + ])), + .init(key: "ranks", value: .sequence([ + .int(1), + .null, + .int(3), + ])), +])) + +let sequenceCatalog = SequenceCatalog( + articles: [sequenceArticle], + featured: ["Typed YAML", nil], + empty: [], +) + +let sequenceCatalogValue = PureYAML.Model.Value.mapping(.init([ + .init(key: "articles", value: .sequence([sequenceArticleValue])), + .init(key: "featured", value: .sequence([ + .string("Typed YAML"), + .null, + ])), + .init(key: "empty", value: .sequence([])), +])) + +struct SequenceDecodeFixture: CustomStringConvertible { + enum Expectation { + case integers([Int]) + case optionalStrings([String?]) + case nestedIntegers([[Int]]) + case articles([SequenceArticle]) + case catalog(SequenceCatalog) + } + + var name: String + var yaml: String + var value: PureYAML.Model.Value + var expectation: Expectation + + var description: String { + name + } + + func expectDecodedFromYAML() throws { + switch expectation { + case let .integers(expected): + #expect(try PureYAML.decode([Int].self, from: yaml) == expected) + case let .optionalStrings(expected): + #expect(try PureYAML.decode([String?].self, from: yaml) == expected) + case let .nestedIntegers(expected): + #expect(try PureYAML.decode([[Int]].self, from: yaml) == expected) + case let .articles(expected): + #expect(try PureYAML.decode([SequenceArticle].self, from: yaml) == expected) + case let .catalog(expected): + #expect(try PureYAML.decode(SequenceCatalog.self, from: yaml) == expected) + } + } + + func expectDecodedFromValue() throws { + switch expectation { + case let .integers(expected): + #expect(try PureYAML.decode([Int].self, from: value) == expected) + case let .optionalStrings(expected): + #expect(try PureYAML.decode([String?].self, from: value) == expected) + case let .nestedIntegers(expected): + #expect(try PureYAML.decode([[Int]].self, from: value) == expected) + case let .articles(expected): + #expect(try PureYAML.decode([SequenceArticle].self, from: value) == expected) + case let .catalog(expected): + #expect(try PureYAML.decode(SequenceCatalog.self, from: value) == expected) + } + } +} + +let sequenceDecodeFixtures: [SequenceDecodeFixture] = [ + .init( + name: "integer sequence", + yaml: """ + - 1 + - 2 + - 3 + """, + value: .sequence([.int(1), .int(2), .int(3)]), + expectation: .integers([1, 2, 3]), + ), + .init( + name: "optional string sequence", + yaml: """ + - one + - null + - three + """, + value: .sequence([ + .string("one"), + .null, + .string("three"), + ]), + expectation: .optionalStrings(["one", nil, "three"]), + ), + .init( + name: "nested integer sequence", + yaml: """ + - [1, 2] + - [] + """, + value: .sequence([ + .sequence([.int(1), .int(2)]), + .sequence([]), + ]), + expectation: .nestedIntegers([[1, 2], []]), + ), + .init( + name: "sequence of keyed articles", + yaml: """ + - title: Typed YAML + tags: + - Swift + - YAML + ranks: + - 1 + - null + - 3 + """, + value: .sequence([sequenceArticleValue]), + expectation: .articles([sequenceArticle]), + ), + .init( + name: "keyed catalog with sequence properties", + yaml: """ + articles: + - title: Typed YAML + tags: [Swift, YAML] + ranks: [1, null, 3] + featured: + - Typed YAML + - null + empty: [] + """, + value: sequenceCatalogValue, + expectation: .catalog(sequenceCatalog), + ), +] diff --git a/Tests/UnkeyedCodingTests.swift b/Tests/UnkeyedCodingTests.swift index f9ea6a3..32dfbd8 100644 --- a/Tests/UnkeyedCodingTests.swift +++ b/Tests/UnkeyedCodingTests.swift @@ -3,6 +3,29 @@ import Testing @Suite("Unkeyed Coding") struct UnkeyedCodingTests { + @Test("Fixture corpus decodes YAML and model sequences", arguments: sequenceDecodeFixtures) + func test_fixtureCorpusDecodesYAMLAndModelSequences( + fixture: SequenceDecodeFixture, + ) throws { + try fixture.expectDecodedFromYAML() + try fixture.expectDecodedFromValue() + } + + @Test("Fixture corpus encodes exact sequence values and YAML", arguments: sequenceEncodeFixtures) + func test_fixtureCorpusEncodesExactSequenceValuesAndYAML( + fixture: SequenceEncodeFixture, + ) throws { + try fixture.expectEncodedValue() + try fixture.expectEncodedYAML() + } + + @Test("Fixture corpus reports exact sequence failures", arguments: sequenceFailureFixtures) + func test_fixtureCorpusReportsExactSequenceFailures( + fixture: SequenceFailureFixture, + ) { + fixture.expectFailure() + } + @Test("Decodes unkeyed sequences with scalars optionals nesting and keyed elements") func test_decodesUnkeyedSequencesWithScalarsOptionalsNestingAndKeyedElements() throws { #expect(try PureYAML.decode([Int].self, from: .sequence([.int(1), .int(2)])) == [1, 2]) diff --git a/Tests/UnkeyedEncodingFixtures.swift b/Tests/UnkeyedEncodingFixtures.swift new file mode 100644 index 0000000..4e3746a --- /dev/null +++ b/Tests/UnkeyedEncodingFixtures.swift @@ -0,0 +1,148 @@ +@testable import PureYAML +import Testing + +struct SequenceEncodeFixture: CustomStringConvertible { + enum Subject { + case integers([Int]) + case optionalStrings([String?]) + case nestedIntegers([[Int]]) + case articles([SequenceArticle]) + case catalog(SequenceCatalog) + } + + var name: String + var subject: Subject + var expectedValue: PureYAML.Model.Value + var expectedYAML: String + var forbiddenYAMLFragments: [String] + + var description: String { + name + } + + func expectEncodedValue() throws { + switch subject { + case let .integers(value): + #expect(try PureYAML.encode(value) == expectedValue) + case let .optionalStrings(value): + #expect(try PureYAML.encode(value) == expectedValue) + case let .nestedIntegers(value): + #expect(try PureYAML.encode(value) == expectedValue) + case let .articles(value): + #expect(try PureYAML.encode(value) == expectedValue) + case let .catalog(value): + #expect(try PureYAML.encode(value) == expectedValue) + } + } + + func expectEncodedYAML() throws { + let yaml = try encodedYAML() + #expect(yaml == expectedYAML) + for fragment in forbiddenYAMLFragments { + #expect(!yaml.contains(fragment)) + } + } + + private func encodedYAML() throws -> String { + switch subject { + case let .integers(value): + try PureYAML.encodeToYAML(value) + case let .optionalStrings(value): + try PureYAML.encodeToYAML(value) + case let .nestedIntegers(value): + try PureYAML.encodeToYAML(value) + case let .articles(value): + try PureYAML.encodeToYAML(value) + case let .catalog(value): + try PureYAML.encodeToYAML(value) + } + } +} + +let sequenceEncodeFixtures: [SequenceEncodeFixture] = [ + .init( + name: "integer sequence", + subject: .integers([1, 2, 3]), + expectedValue: .sequence([.int(1), .int(2), .int(3)]), + expectedYAML: """ + - 1 + - 2 + - 3 + + """, + forbiddenYAMLFragments: ["null", "title:"], + ), + .init( + name: "optional string sequence", + subject: .optionalStrings(["one", nil, "three"]), + expectedValue: .sequence([ + .string("one"), + .null, + .string("three"), + ]), + expectedYAML: """ + - "one" + - null + - "three" + + """, + forbiddenYAMLFragments: ["- 0"], + ), + .init( + name: "nested integer sequence", + subject: .nestedIntegers([[1, 2], []]), + expectedValue: .sequence([ + .sequence([.int(1), .int(2)]), + .sequence([]), + ]), + expectedYAML: """ + - + - 1 + - 2 + - + + """, + forbiddenYAMLFragments: ["null"], + ), + .init( + name: "sequence of keyed articles", + subject: .articles([sequenceArticle]), + expectedValue: .sequence([sequenceArticleValue]), + expectedYAML: """ + - + title: "Typed YAML" + tags: + - "Swift" + - "YAML" + ranks: + - 1 + - null + - 3 + + """, + forbiddenYAMLFragments: ["missing:", "empty:"], + ), + .init( + name: "keyed catalog with sequence properties", + subject: .catalog(sequenceCatalog), + expectedValue: sequenceCatalogValue, + expectedYAML: """ + articles: + - + title: "Typed YAML" + tags: + - "Swift" + - "YAML" + ranks: + - 1 + - null + - 3 + featured: + - "Typed YAML" + - null + empty: + + """, + forbiddenYAMLFragments: ["missing:", "- 0"], + ), +] diff --git a/Tests/UnkeyedFailureFixtures.swift b/Tests/UnkeyedFailureFixtures.swift new file mode 100644 index 0000000..fb47be7 --- /dev/null +++ b/Tests/UnkeyedFailureFixtures.swift @@ -0,0 +1,137 @@ +@testable import PureYAML +import Testing + +struct SequenceFailureFixture: CustomStringConvertible { + enum Operation { + case decodeIntegers + case decodeNestedIntegers + case decodeArticles + case decodeCatalog + } + + enum Expected { + case decoding(PureYAML.Decoding.Error) + case validation([PureYAML.Validation.Issue], String) + } + + var name: String + var value: PureYAML.Model.Value + var operation: Operation + var expected: Expected + + var description: String { + name + } + + func expectFailure() { + do { + switch operation { + case .decodeIntegers: + _ = try PureYAML.decode([Int].self, from: value) + case .decodeNestedIntegers: + _ = try PureYAML.decode([[Int]].self, from: value) + case .decodeArticles: + _ = try PureYAML.decode([SequenceArticle].self, from: value) + case .decodeCatalog: + _ = try PureYAML.decode(SequenceCatalog.self, from: value) + } + recordIssue("expected fixture failure") + } catch let error as PureYAML.Decoding.Error { + guard case let .decoding(expected) = expected else { + recordIssue("unexpected decoding error \(error)") + return + } + #expect(error == expected) + #expect(error.description == expected.description) + } catch let collection as PureYAML.Validation.Issue.Collection { + guard case let .validation(issues, description) = expected else { + recordIssue("unexpected validation error \(collection)") + return + } + #expect(collection.issues == issues) + #expect(collection.description == description) + } catch { + recordIssue("unexpected error \(error)") + } + } +} + +let sequenceFailureFixtures: [SequenceFailureFixture] = [ + .init( + name: "sequence root is required", + value: .string("not a sequence"), + operation: .decodeIntegers, + expected: .decoding(.typeMismatch(expected: "sequence", actual: "string", path: .root)), + ), + .init( + name: "integer sequence rejects string element", + value: .sequence([.int(1), .string("two")]), + operation: .decodeIntegers, + expected: .decoding(.typeMismatch(expected: "Int", actual: "string", path: .init([.index(1)]))), + ), + .init( + name: "nested integer sequence rejects bool element", + value: .sequence([ + .sequence([.int(1), .bool(false)]), + ]), + operation: .decodeNestedIntegers, + expected: .decoding(.typeMismatch( + expected: "Int", + actual: "bool", + path: .init([.index(0), .index(1)]), + )), + ), + .init( + name: "article sequence rejects missing title", + value: .sequence([ + .mapping(.init([ + .init(key: "tags", value: .sequence([])), + .init(key: "ranks", value: .sequence([])), + ])), + ]), + operation: .decodeArticles, + expected: .decoding(.keyNotFound(key: "title", path: .init([.index(0), .key("title")]))), + ), + .init( + name: "catalog rejects non-sequence tags", + value: .mapping(.init([ + .init(key: "articles", value: .sequence([ + .mapping(.init([ + .init(key: "title", value: .string("Typed YAML")), + .init(key: "tags", value: .string("Swift")), + .init(key: "ranks", value: .sequence([])), + ])), + ])), + .init(key: "featured", value: .sequence([])), + .init(key: "empty", value: .sequence([])), + ])), + operation: .decodeCatalog, + expected: .decoding(.typeMismatch( + expected: "sequence", + actual: "string", + path: .init([.key("articles"), .index(0), .key("tags")]), + )), + ), + .init( + name: "duplicate keys in sequence are validation failures before decoding", + value: .sequence([ + .mapping(.init([ + .init(key: "title", value: .string("First")), + .init(key: "title", value: .string("Second")), + .init(key: "tags", value: .sequence([])), + .init(key: "ranks", value: .sequence([])), + ])), + ]), + operation: .decodeArticles, + expected: .validation( + [ + .init( + severity: .error, + reason: "Duplicate mapping key 'title'", + path: .init([.index(0), .key("title")]), + ), + ], + "error: Duplicate mapping key 'title' at $[0].title", + ), + ), +] diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 7346b6f..941a436 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -55,6 +55,7 @@ Before claiming completion, run: ```sh bash scripts/check-style.sh bash scripts/check-namespacing.sh +bash scripts/check-forbidden-patterns.sh bash scripts/check-changelog-touched.sh swiftformat . --config .swiftformat --lint swiftlint --config .swiftlint.yml --strict diff --git a/docs/rules/verification.md b/docs/rules/verification.md index fd4979c..9edcbae 100644 --- a/docs/rules/verification.md +++ b/docs/rules/verification.md @@ -10,6 +10,7 @@ Run this before committing or reporting completion: ```sh bash scripts/check-style.sh bash scripts/check-namespacing.sh +bash scripts/check-forbidden-patterns.sh bash scripts/check-changelog-touched.sh swiftformat . --config .swiftformat --lint swiftlint --config .swiftlint.yml --strict @@ -25,6 +26,7 @@ bash scripts/check-wasm.sh |---|---| | Style clean | `scripts/check-style.sh` exits 0 | | Namespace clean | `scripts/check-namespacing.sh` exits 0 | +| Forbidden-pattern clean | `scripts/check-forbidden-patterns.sh` exits 0 | | Format clean | `swiftformat . --config .swiftformat --lint` exits 0 | | Lint clean | `swiftlint --config .swiftlint.yml --strict` exits 0 | | Builds on macOS | `swift build` exits 0 | diff --git a/scripts/check-all.sh b/scripts/check-all.sh index 74c3486..57abb81 100755 --- a/scripts/check-all.sh +++ b/scripts/check-all.sh @@ -15,6 +15,7 @@ run_gate() { run_gate "style" bash scripts/check-style.sh run_gate "namespacing" bash scripts/check-namespacing.sh +run_gate "forbidden patterns" bash scripts/check-forbidden-patterns.sh run_gate "changelog" bash scripts/check-changelog-touched.sh run_gate "roadmap" bash scripts/check-roadmap.sh run_gate "swiftformat" swiftformat . --config .swiftformat --lint diff --git a/scripts/check-forbidden-patterns.sh b/scripts/check-forbidden-patterns.sh new file mode 100644 index 0000000..97f894c --- /dev/null +++ b/scripts/check-forbidden-patterns.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Repo contract gate for PureYAML "never" rules. +# +# These checks cover patterns that Swift unit tests cannot see reliably: +# package dependencies, source imports, C/generated parser files, XCTest-style +# tests, platform branches, and singleton/service-locator wiring. + +set -u + +FAIL=0 + +fail() { + echo "forbidden-patterns: $1" >&2 + FAIL=1 +} + +report_matches() { + message="$1" + matches="$2" + if [ -n "$matches" ]; then + fail "$message" + printf '%s\n' "$matches" | sed 's/^/ /' >&2 + fi +} + +if ! grep -qE '^[[:space:]]*dependencies:[[:space:]]*\[[[:space:]]*\],[[:space:]]*$' Package.swift; then + fail "Package.swift must keep dependencies: []" +fi + +package_forbidden=$( + grep -nE '\.package[[:space:]]*\(|\.binaryTarget[[:space:]]*\(|\.systemLibrary[[:space:]]*\(' Package.swift 2>/dev/null || true +) +report_matches "Package.swift must not declare external, binary, or system-library targets" "$package_forbidden" + +source_imports=$( + grep -RInE '^[[:space:]]*import[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' Sources --include '*.swift' 2>/dev/null || true +) +report_matches "Sources must stay standard-library-only and import no modules" "$source_imports" + +source_platform_branches=$( + grep -RInE '^[[:space:]]*#if[[:space:]]+(os|canImport)[[:space:]]*\(' Sources --include '*.swift' 2>/dev/null || true +) +report_matches "Sources must not add platform-specific branches without an explicit rule change" "$source_platform_branches" + +source_runtimes=$( + find Sources -type f \( \ + -name '*.c' -o \ + -name '*.cc' -o \ + -name '*.cpp' -o \ + -name '*.h' -o \ + -name '*.hpp' -o \ + -name '*.m' -o \ + -name '*.mm' -o \ + -name '*.js' -o \ + -name '*.ts' -o \ + -name '*.wasm' -o \ + -name '*.y' -o \ + -name '*.l' -o \ + -name '*.g4' \ + \) -print 2>/dev/null +) +report_matches "Sources must not contain C, JavaScript, WebAssembly blobs, or generated parser runtime files" "$source_runtimes" + +source_singletons=$( + grep -RInE 'static[[:space:]]+(let|var)[[:space:]]+shared\b|^[[:space:]]*(public[[:space:]]+)?(final[[:space:]]+)?(class|struct|enum)[[:space:]]+ServiceLocator\b|[A-Za-z0-9_]*(Resolver|Container|Environment)[[:space:]]*\.[[:space:]]*shared\b' Sources --include '*.swift' 2>/dev/null || true +) +report_matches "Sources must not introduce singleton or service-locator access patterns" "$source_singletons" + +test_imports=$( + grep -RInE '^[[:space:]]*(@testable[[:space:]]+)?import[[:space:]]+[A-Za-z_][A-Za-z0-9_]*' Tests --include '*.swift' 2>/dev/null \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*@testable[[:space:]]+import[[:space:]]+PureYAML[[:space:]]*$' \ + | grep -vE '^[^:]+:[0-9]+:[[:space:]]*import[[:space:]]+Testing[[:space:]]*$' || true +) +report_matches "Tests may import only Testing and @testable PureYAML" "$test_imports" + +test_xctest=$( + grep -RInE '\bXCTest(Case|Expectation)?\b|\bXCTAssert[A-Za-z]*\b|^[[:space:]]*import[[:space:]]+XCTest[[:space:]]*$' Tests --include '*.swift' 2>/dev/null || true +) +report_matches "Tests must stay on Swift Testing and avoid XCTest-style APIs" "$test_xctest" + +if [ "$FAIL" -eq 0 ]; then + echo "forbidden-patterns: OK" +fi + +exit "$FAIL" diff --git a/scripts/check-namespacing.sh b/scripts/check-namespacing.sh index b7bddb0..791bcc4 100755 --- a/scripts/check-namespacing.sh +++ b/scripts/check-namespacing.sh @@ -20,6 +20,18 @@ while IFS= read -r f; do echo "namespacing: $count file-scope types in $f (one per file)" >&2 FAIL=1 fi + + public_types=$(grep -nE "^public (actor|struct|enum|protocol|class|final class) [A-Z]" "$f" || true) + if [ -n "$public_types" ]; then + if [ "$f" = "Sources/PureYAML.swift" ] && + [ "$public_types" = "2:public enum PureYAML {" ]; then + : + else + echo "namespacing: public file-scope type in $f (wrap it under PureYAML)" >&2 + printf '%s\n' "$public_types" | sed 's/^/ /' >&2 + FAIL=1 + fi + fi done < <(find "$SRC" -name "*.swift") if [ "$FAIL" -ne 0 ]; then