From 29ecce451a7a10a5b71f4aa24de239c3ffb59cb1 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Fri, 5 Jun 2026 12:49:20 +0200 Subject: [PATCH] feat(codable): broaden compatibility coverage --- AGENTS.md | 5 + CHANGELOG.md | 10 + README.md | 24 +- Sources/Coding/SuperCodingKey.swift | 18 ++ .../Decoding/Decoding.KeyedContainer.swift | 15 +- .../Encoding/Encoding.KeyedContainer.swift | 13 +- Tests/CodableCompatibilityFixtures.swift | 269 ++++++++++++++++++ Tests/CodableCompatibilityTests.swift | 247 ++++++++++++++++ docs/rules/research-compatibility.md | 45 +++ 9 files changed, 613 insertions(+), 33 deletions(-) create mode 100644 Sources/Coding/SuperCodingKey.swift create mode 100644 Tests/CodableCompatibilityFixtures.swift create mode 100644 Tests/CodableCompatibilityTests.swift create mode 100644 docs/rules/research-compatibility.md diff --git a/AGENTS.md b/AGENTS.md index b8d6484..683ba4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ For code changes, load at minimum: - `docs/rules/testing.md` - `docs/rules/verification.md` - `docs/rules/commits.md` +- `docs/rules/research-compatibility.md` ## What PureYAML Is @@ -35,6 +36,10 @@ Do not copy C code or upstream Yams source into this public package. Keep attribution in `ATTRIBUTION.md` accurate when compatibility work references Yams or CYaml behavior. +Compatibility work must start from that research source. Before changing parser, +emitter, validation, or typed conversion behavior, inspect the matching +PureYAMLResearch source or fixture and use it only to define behavior and tests. + ## Namespace Rules Every public type lives under the `PureYAML` namespace tree and mirrors its diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c68aa..8d7d430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. +- Add broader Codable compatibility coverage for dynamic mapping keys, nested + container round trips, keyed super coders, default `super` coders, and + deterministic unsupported-shape errors. +- Add an explicit research-compatibility rule requiring parser, emitter, + validation, and typed conversion work to start from PureYAMLResearch behavior + without copying implementation code. ### Changed @@ -66,3 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Harden duplicate-key validation to use set membership while preserving exact diagnostics. - Make validation rules immutable after construction. +- Preserve keyed super-encoder field order even when delayed child encoders are + written in a different order than they were requested. +- Make keyed default `superEncoder()` and `superDecoder()` use the standard + `super` mapping key instead of the current mapping. diff --git a/README.md b/README.md index e762d35..dbd2739 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ flowchart TB SuperEpic8["#8 Parser Replacement Roadmap"]:::epic Epic1["#1 Pure Swift Parse Core"]:::done Epic9["#9 Deterministic Emitter Core"]:::done - Epic10["#10 Typed Decoding and Encoding"]:::todo + Epic10["#10 Typed Decoding and Encoding"]:::done Epic11["#11 YAML Compatibility Corpus"]:::todo Epic12["#12 Usage and Migration Docs"]:::todo Epic13["#13 Release Readiness"]:::todo @@ -61,24 +61,7 @@ flowchart TB #9 Deterministic Emitter Core is complete in main. -#10 Typed Decoding and Encoding is active: - -```mermaid -flowchart TB - classDef done fill:#ddf9e4,stroke:#34c759,color:#111827 - classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827 - classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827 - classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827 - Epic10["#10 Typed Decoding and Encoding"]:::epic - Scalar20["#20 Scalar Typed Conversion"]:::done - Keyed21["#21 Keyed Typed Conversion"]:::done - Unkeyed22["#22 Unkeyed Typed Conversion"]:::done - Compat23["#23 Broader Codable Compatibility"]:::todo - Epic10 --> Scalar20 - Scalar20 --> Keyed21 - Keyed21 --> Unkeyed22 - Unkeyed22 --> Compat23 -``` +#10 Typed Decoding and Encoding is complete in main. ## Status @@ -121,7 +104,8 @@ try PureYAML.validate(document) ``` Typed conversion is available for scalar values, keyed mapping-backed structs, -and unkeyed sequences: +dictionary-like string mappings, unkeyed sequences, nested containers, and +keyed super coders: ```swift struct Info: Codable { diff --git a/Sources/Coding/SuperCodingKey.swift b/Sources/Coding/SuperCodingKey.swift new file mode 100644 index 0000000..3f8eab8 --- /dev/null +++ b/Sources/Coding/SuperCodingKey.swift @@ -0,0 +1,18 @@ +struct SuperCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + + init() { + stringValue = "super" + intValue = nil + } + + init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(intValue _: Int) { + nil + } +} diff --git a/Sources/Decoding/Decoding.KeyedContainer.swift b/Sources/Decoding/Decoding.KeyedContainer.swift index df4cc0b..5c8aeae 100644 --- a/Sources/Decoding/Decoding.KeyedContainer.swift +++ b/Sources/Decoding/Decoding.KeyedContainer.swift @@ -117,23 +117,18 @@ struct KeyedDecodingContainerImpl: KeyedDecodingContainerProtoco } func superDecoder() throws -> any Swift.Decoder { - PureYAML.Decoding.Decoder( - value: .mapping(mapping), - codingPath: codingPath, - userInfo: userInfo, - validatesInput: validatesInput, - ) + try decoder(for: SuperCodingKey()) } func superDecoder(forKey key: Key) throws -> any Swift.Decoder { try decoder(for: key) } - private func singleValue(for key: Key) throws -> any SingleValueDecodingContainer { + private func singleValue(for key: any CodingKey) throws -> any SingleValueDecodingContainer { try decoder(for: key).singleValueContainer() } - private func decoder(for key: Key) throws -> PureYAML.Decoding.Decoder { + private func decoder(for key: any CodingKey) throws -> PureYAML.Decoding.Decoder { try PureYAML.Decoding.Decoder( value: value(for: key), codingPath: codingPath + [key], @@ -142,7 +137,7 @@ struct KeyedDecodingContainerImpl: KeyedDecodingContainerProtoco ) } - private func value(for key: Key) throws -> PureYAML.Model.Value { + private func value(for key: any CodingKey) throws -> PureYAML.Model.Value { guard let value = mapping[key.stringValue] else { throw PureYAML.Decoding.Error.keyNotFound( key: key.stringValue, @@ -154,7 +149,7 @@ struct KeyedDecodingContainerImpl: KeyedDecodingContainerProtoco private func validateIfNeeded( _ value: PureYAML.Model.Value, - forKey key: Key, + forKey key: any CodingKey, ) throws { if validatesInput { try PureYAML.Decoding.validate(value, codingPath: codingPath + [key]) diff --git a/Sources/Encoding/Encoding.KeyedContainer.swift b/Sources/Encoding/Encoding.KeyedContainer.swift index 2865e6a..4ae61f8 100644 --- a/Sources/Encoding/Encoding.KeyedContainer.swift +++ b/Sources/Encoding/Encoding.KeyedContainer.swift @@ -130,14 +130,21 @@ struct KeyedEncodingContainerImpl: KeyedEncodingContainerProtoco } mutating func superEncoder() -> any Swift.Encoder { - PureYAML.Encoding.Encoder( - codingPath: codingPath, + let key = SuperCodingKey() + storage.set(.null, forKey: key.stringValue) + let childStorage = PureYAML.Encoding.Encoder.Storage( + parent: storage, + parentKey: key.stringValue, + ) + return PureYAML.Encoding.Encoder( + codingPath: codingPath + [key], userInfo: userInfo, - storage: storage, + storage: childStorage, ) } mutating func superEncoder(forKey key: Key) -> any Swift.Encoder { + storage.set(.null, forKey: key.stringValue) let childStorage = PureYAML.Encoding.Encoder.Storage( parent: storage, parentKey: key.stringValue, diff --git a/Tests/CodableCompatibilityFixtures.swift b/Tests/CodableCompatibilityFixtures.swift new file mode 100644 index 0000000..ec09b37 --- /dev/null +++ b/Tests/CodableCompatibilityFixtures.swift @@ -0,0 +1,269 @@ +@testable import PureYAML + +let dynamicSettingsValue = PureYAML.Model.Value.mapping(.init([ + .init(key: "/users", value: .int(2)), + .init(key: "name.with.dot", value: .int(3)), +])) + +let nestedCompatibilityValue = PureYAML.Model.Value.mapping(.init([ + .init(key: "title", value: .string("Typed YAML")), + .init(key: "metadata", value: .mapping(.init([ + .init(key: "author", value: .string("Mihaela")), + .init(key: "rating", value: .int(5)), + ]))), + .init(key: "sections", value: .sequence([ + .mapping(.init([ + .init(key: "heading", value: .string("Intro")), + .init(key: "items", value: .sequence([ + .string("one"), + .string("two"), + ])), + ])), + .mapping(.init([ + .init(key: "heading", value: .string("Outro")), + .init(key: "items", value: .sequence([])), + ])), + ])), +])) + +let nestedCompatibilityFixture = NestedCompatibilityDocument( + title: "Typed YAML", + metadata: .init(author: "Mihaela", rating: 5), + sections: [ + .init(heading: "Intro", items: ["one", "two"]), + .init(heading: "Outro", items: []), + ], +) + +let codingPathProbeValue = PureYAML.Model.Value.mapping(.init([ + .init(key: "payload", value: .string("child")), + .init(key: "metadata", value: .mapping(.init([ + .init(key: "author", value: .string("Mihaela")), + ]))), + .init(key: "items", value: .sequence([ + .mapping(.init([ + .init(key: "author", value: .string("Nested")), + ])), + ])), +])) + +struct DynamicSettings: Codable, Equatable { + struct Entry: Equatable { + var key: String + var value: Int + } + + var entries: [Entry] + + init(entries: [Entry]) { + self.entries = entries + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + entries = try container.allKeys.map { key in + try Entry( + key: key.stringValue, + value: container.decode(Int.self, forKey: key), + ) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: DynamicCodingKey.self) + for entry in entries { + try container.encode( + entry.value, + forKey: DynamicCodingKey(entry.key), + ) + } + } +} + +struct NestedCompatibilityDocument: Codable, Equatable { + var title: String + var metadata: NestedCompatibilityMetadata + var sections: [NestedCompatibilitySection] +} + +struct NestedCompatibilityMetadata: Codable, Equatable { + var author: String + var rating: Int +} + +struct NestedCompatibilitySection: Codable, Equatable { + var heading: String + var items: [String] +} + +struct SuperCodedPayload: Codable, Equatable { + var value: String +} + +struct KeyedSuperBox: Codable, Equatable { + enum CodingKeys: String, CodingKey { + case payload + } + + var payload: SuperCodedPayload + + init(payload: SuperCodedPayload) { + self.payload = payload + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + payload = try SuperCodedPayload( + from: container.superDecoder(forKey: .payload), + ) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try payload.encode(to: container.superEncoder(forKey: .payload)) + } +} + +struct KeyedDefaultSuperBox: Codable, Equatable { + var payload: SuperCodedPayload + + init(payload: SuperCodedPayload) { + self.payload = payload + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingPathProbeTopKey.self) + let superDecoder = try container.superDecoder() + expectCodingPath(superDecoder.codingPath, strings: ["super"], intValues: [nil]) + payload = try SuperCodedPayload(from: superDecoder) + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingPathProbeTopKey.self) + let superEncoder = container.superEncoder() + expectCodingPath(superEncoder.codingPath, strings: ["super"], intValues: [nil]) + try payload.encode(to: superEncoder) + } +} + +struct DelayedKeyedSuperEncoders: Encodable { + enum CodingKeys: String, CodingKey { + case first + case second + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + let first = container.superEncoder(forKey: .first) + let second = container.superEncoder(forKey: .second) + try "two".encode(to: second) + try "one".encode(to: first) + } +} + +struct MissingPayloadBox: Decodable { + enum CodingKeys: String, CodingKey { + case payload + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + _ = try container.superDecoder(forKey: .payload) + } +} + +struct MissingDefaultSuperBox: Decodable { + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingPathProbeTopKey.self) + _ = try container.superDecoder() + } +} + +struct NoValueEncoded: Encodable { + func encode(to _: any Encoder) throws {} +} + +struct KeyedCodingPathEncodingProbe: Encodable { + func encode(to encoder: any Encoder) throws { + expectCodingPath(encoder.codingPath, strings: [], intValues: []) + + var container = encoder.container(keyedBy: CodingPathProbeTopKey.self) + expectCodingPath(container.codingPath, strings: [], intValues: []) + + let superEncoder = container.superEncoder(forKey: .payload) + expectCodingPath(superEncoder.codingPath, strings: ["payload"], intValues: [nil]) + + var metadata = container.nestedContainer( + keyedBy: CodingPathProbeNestedKey.self, + forKey: .metadata, + ) + expectCodingPath(metadata.codingPath, strings: ["metadata"], intValues: [nil]) + try metadata.encode("Mihaela", forKey: .author) + + var items = container.nestedUnkeyedContainer(forKey: .items) + expectCodingPath(items.codingPath, strings: ["items"], intValues: [nil]) + + var item = items.nestedContainer(keyedBy: CodingPathProbeNestedKey.self) + expectCodingPath(item.codingPath, strings: ["items", "0"], intValues: [nil, 0]) + try item.encode("Nested", forKey: .author) + + try "child".encode(to: superEncoder) + } +} + +struct KeyedCodingPathDecodingProbe: Decodable { + init(from decoder: any Decoder) throws { + expectCodingPath(decoder.codingPath, strings: [], intValues: []) + + let container = try decoder.container(keyedBy: CodingPathProbeTopKey.self) + expectCodingPath(container.codingPath, strings: [], intValues: []) + + let superDecoder = try container.superDecoder(forKey: .payload) + expectCodingPath(superDecoder.codingPath, strings: ["payload"], intValues: [nil]) + _ = try superDecoder.singleValueContainer().decode(String.self) + + let metadata = try container.nestedContainer( + keyedBy: CodingPathProbeNestedKey.self, + forKey: .metadata, + ) + expectCodingPath(metadata.codingPath, strings: ["metadata"], intValues: [nil]) + _ = try metadata.decode(String.self, forKey: .author) + + var items = try container.nestedUnkeyedContainer(forKey: .items) + expectCodingPath(items.codingPath, strings: ["items"], intValues: [nil]) + + let item = try items.nestedContainer(keyedBy: CodingPathProbeNestedKey.self) + expectCodingPath(item.codingPath, strings: ["items", "0"], intValues: [nil, 0]) + _ = try item.decode(String.self, forKey: .author) + } +} + +enum CodingPathProbeTopKey: String, CodingKey { + case payload + case metadata + case items +} + +enum CodingPathProbeNestedKey: String, CodingKey { + case author +} + +struct DynamicCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + + init(_ stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(intValue: Int) { + stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/Tests/CodableCompatibilityTests.swift b/Tests/CodableCompatibilityTests.swift new file mode 100644 index 0000000..2682f05 --- /dev/null +++ b/Tests/CodableCompatibilityTests.swift @@ -0,0 +1,247 @@ +@testable import PureYAML +import Testing + +@Suite("Codable Compatibility") +struct CodableCompatibilityTests { + @Test("Dictionary-like dynamic keys decode from ordered mappings") + func test_dictionaryLikeDynamicKeysDecodeFromOrderedMappings() throws { + let decoded = try PureYAML.decode( + DynamicSettings.self, + from: dynamicSettingsValue, + ) + + #expect(decoded == DynamicSettings(entries: [ + .init(key: "/users", value: 2), + .init(key: "name.with.dot", value: 3), + ])) + } + + @Test("Dictionary-like dynamic keys encode exact values and YAML") + func test_dictionaryLikeDynamicKeysEncodeExactValuesAndYAML() throws { + let settings = DynamicSettings(entries: [ + .init(key: "/users", value: 2), + .init(key: "name.with.dot", value: 3), + ]) + + let yaml = try PureYAML.encodeToYAML(settings) + + #expect(try PureYAML.encode(settings) == dynamicSettingsValue) + #expect(yaml == """ + /users: 2 + name.with.dot: 3 + + """) + #expect(yaml.contains("/users: 2")) + #expect(yaml.contains("name.with.dot: 3")) + #expect(!yaml.contains("\"name.with.dot\"")) + #expect(!yaml.contains("null")) + } + + @Test("Dictionary values decode where YAML mapping semantics allow them") + func test_dictionaryValuesDecodeWhereYAMLSemanticsAllowThem() throws { + let decoded = try PureYAML.decode( + [String: Int].self, + from: .mapping(.init([ + .init(key: "count", value: .int(2)), + ])), + ) + + #expect(decoded == ["count": 2]) + } + + @Test("Nested keyed unkeyed and single-value interactions round trip exactly") + func test_nestedKeyedUnkeyedAndSingleValueInteractionsRoundTripExactly() throws { + let decoded = try PureYAML.decode( + NestedCompatibilityDocument.self, + from: nestedCompatibilityValue, + ) + let yaml = try PureYAML.encodeToYAML(nestedCompatibilityFixture) + + #expect(decoded == nestedCompatibilityFixture) + #expect(try PureYAML.encode(nestedCompatibilityFixture) == nestedCompatibilityValue) + #expect(yaml == """ + title: "Typed YAML" + metadata: + author: "Mihaela" + rating: 5 + sections: + - + heading: "Intro" + items: + - "one" + - "two" + - + heading: "Outro" + items: + + """) + #expect(yaml.contains("sections:")) + #expect(!yaml.contains("summary:")) + } + + @Test("Dynamic-key decoding reports exact punctuation paths") + func test_dynamicKeyDecodingReportsExactPunctuationPaths() { + expectDecodingError( + .typeMismatch( + expected: "Int", + actual: "string", + path: .init([.key("/users")]), + ), + ) { + _ = try PureYAML.decode(DynamicSettings.self, from: .mapping(.init([ + .init(key: "/users", value: .string("two")), + ]))) + } + } + + @Test("Dynamic-key decoding validates duplicate punctuation keys") + func test_dynamicKeyDecodingValidatesDuplicatePunctuationKeys() { + expectValidationError { + _ = try PureYAML.decode(DynamicSettings.self, from: .mapping(.init([ + .init(key: "/users", value: .int(1)), + .init(key: "/users", value: .int(2)), + ]))) + } check: { collection in + #expect(collection.issues == [ + .init( + severity: .error, + reason: "Duplicate mapping key '/users'", + path: .init([.key("/users")]), + ), + ]) + #expect(collection.description == "error: Duplicate mapping key '/users' at $[\"/users\"]") + } + } + + @Test("Keyed super decoders and encoders preserve exact payloads") + func test_keyedSuperDecodersAndEncodersPreserveExactPayloads() throws { + let value = PureYAML.Model.Value.mapping(.init([ + .init(key: "payload", value: .mapping(.init([ + .init(key: "value", value: .string("child")), + ]))), + ])) + let box = KeyedSuperBox(payload: .init(value: "child")) + + #expect(try PureYAML.decode(KeyedSuperBox.self, from: value) == box) + #expect(try PureYAML.encode(box) == value) + #expect(try PureYAML.encodeToYAML(box) == """ + payload: + value: "child" + + """) + } + + @Test("Keyed default super coders use the super key") + func test_keyedDefaultSuperCodersUseTheSuperKey() throws { + let value = PureYAML.Model.Value.mapping(.init([ + .init(key: "super", value: .mapping(.init([ + .init(key: "value", value: .string("child")), + ]))), + ])) + let box = KeyedDefaultSuperBox(payload: .init(value: "child")) + + #expect(try PureYAML.decode(KeyedDefaultSuperBox.self, from: value) == box) + #expect(try PureYAML.encode(box) == value) + #expect(try PureYAML.encodeToYAML(box) == """ + super: + value: "child" + + """) + } + + @Test("Keyed super encoders reserve distinct keys before values are written") + func test_keyedSuperEncodersReserveDistinctKeysBeforeValuesAreWritten() throws { + #expect(try PureYAML.encode(DelayedKeyedSuperEncoders()) == .mapping(.init([ + .init(key: "first", value: .string("one")), + .init(key: "second", value: .string("two")), + ]))) + } + + @Test("Nested coders expose exact coding paths") + func test_nestedCodersExposeExactCodingPaths() throws { + #expect(try PureYAML.encode(KeyedCodingPathEncodingProbe()) == codingPathProbeValue) + + _ = try PureYAML.decode( + KeyedCodingPathDecodingProbe.self, + from: codingPathProbeValue, + ) + } + + @Test("Unsupported missing super decoder payload reports exact errors") + func test_unsupportedMissingSuperDecoderPayloadReportsExactErrors() { + expectDecodingError( + .keyNotFound(key: "payload", path: .init([.key("payload")])), + ) { + _ = try PureYAML.decode(MissingPayloadBox.self, from: .mapping(.init())) + } + } + + @Test("Unsupported missing default super decoder reports exact errors") + func test_unsupportedMissingDefaultSuperDecoderReportsExactErrors() { + expectDecodingError( + .keyNotFound(key: "super", path: .init([.key("super")])), + ) { + _ = try PureYAML.decode(MissingDefaultSuperBox.self, from: .mapping(.init())) + } + } + + @Test("Unsupported empty encodable reports exact errors") + func test_unsupportedEmptyEncodableReportsExactErrors() { + expectEncodingError(.noValueEncoded(path: .root)) { + _ = try PureYAML.encode(NoValueEncoded()) + } + } +} + +private func expectDecodingError( + _ expected: PureYAML.Decoding.Error, + operation: () throws -> some Any, +) { + do { + _ = try operation() + recordIssue("expected decoding error") + } catch let error as PureYAML.Decoding.Error { + #expect(error == expected) + #expect(error.description == expected.description) + } catch { + recordIssue("unexpected error \(error)") + } +} + +private func expectEncodingError( + _ expected: PureYAML.Encoding.Error, + operation: () throws -> some Any, +) { + do { + _ = try operation() + recordIssue("expected encoding error") + } catch let error as PureYAML.Encoding.Error { + #expect(error == expected) + #expect(error.description == expected.description) + } catch { + recordIssue("unexpected error \(error)") + } +} + +private func expectValidationError( + operation: () throws -> some Any, + check: (PureYAML.Validation.Issue.Collection) -> Void, +) { + do { + _ = try operation() + recordIssue("expected validation error") + } catch let collection as PureYAML.Validation.Issue.Collection { + check(collection) + } catch { + recordIssue("unexpected error \(error)") + } +} + +func expectCodingPath( + _ path: [any CodingKey], + strings: [String], + intValues: [Int?], +) { + #expect(path.map(\.stringValue) == strings) + #expect(path.map(\.intValue) == intValues) +} diff --git a/docs/rules/research-compatibility.md b/docs/rules/research-compatibility.md new file mode 100644 index 0000000..259a4eb --- /dev/null +++ b/docs/rules/research-compatibility.md @@ -0,0 +1,45 @@ +# Research Compatibility Rules + +PureYAML replaces behavior that is currently studied in the private +`PureYAMLResearch` repository. Compatibility work must be research-led and +implementation-independent. + +## Mandatory Start Point + +Before changing parser, scanner, composer, emitter, validation, or typed +Codable conversion behavior: + +- inspect the matching source, tests, or fixtures in `PureYAMLResearch`; +- identify the observable behavior PureYAML should match or deliberately reject; +- write PureYAML tests that pin that behavior before or with the implementation; +- keep all public-package code original and dependency-free. + +## Copying Boundary + +Do not copy source from Yams, CYaml, libyaml, or research-only references into +`Sources/`. Use the research repository for behavior discovery only. + +Allowed: + +- exact behavioral assertions in PureYAML tests; +- attribution in `ATTRIBUTION.md` and design docs; +- small independently written fixtures that describe input and expected output. + +Forbidden: + +- copied parser, scanner, emitter, or encoder implementation code; +- C files, generated parser files, or external SwiftPM dependencies; +- compatibility claims that were not checked against the research source or a + deliberate PureYAML design decision. + +## Test Contract + +Every compatibility test must pin at least one of: + +- exact parsed value tree; +- exact emitted YAML; +- exact decoded Swift value; +- exact encoded value tree; +- exact validation issue; +- exact path-aware decoding or encoding error; +- exact required absence check for fields that must not appear.