test(coding): add sequence fixtures and contract gate

This commit is contained in:
Mihaela Mihaljevic 2026-06-05 12:33:09 +02:00
parent 5c3de786bf
commit 611c079255
12 changed files with 579 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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),
),
]

View File

@ -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])

View File

@ -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"],
),
]

View File

@ -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",
),
),
]

View File

@ -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

View File

@ -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 |

View File

@ -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

View File

@ -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"

View File

@ -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