From 015d674f0fab4d34b08d6f2257a94eccac8c5dbd Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Wed, 30 Apr 2025 11:53:31 -0400 Subject: [PATCH 01/50] Ingest SwiftSyntax 601.0.0-latest --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index d87f74da31..1b57c337c6 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( .plugin(name: "MetaProtocolCodable", targets: ["MetaProtocolCodable"]), ], dependencies: [ - .package(url: "https://github.com/swiftlang/swift-syntax.git", "509.1.0"..<"601.0.0"), + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "601.0.0-latest"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2"), .package(url: "https://github.com/swiftlang/swift-format", from: "600.0.0"), From 45bc3ab2932574e4b977d6fd80ec9795d53501ea Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Wed, 30 Apr 2025 13:58:10 -0400 Subject: [PATCH 02/50] Account for failable (optional) TypeSyntax initializer in SwiftSyntax 601. --- Sources/PluginCore/Attributes/CodedAs.swift | 3 ++- Sources/PluginCore/Variables/Property/PropertyVariable.swift | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/PluginCore/Attributes/CodedAs.swift b/Sources/PluginCore/Attributes/CodedAs.swift index e8f2712ccd..793f365946 100644 --- a/Sources/PluginCore/Attributes/CodedAs.swift +++ b/Sources/PluginCore/Attributes/CodedAs.swift @@ -23,8 +23,9 @@ package struct CodedAs: PropertyAttribute { /// Used for enums with internal/adjacent tagging to decode /// the identifier to this type. var type: TypeSyntax? { - return node.attributeName.as(IdentifierTypeSyntax.self)? + let argument = node.attributeName.as(IdentifierTypeSyntax.self)? .genericArgumentClause?.arguments.first?.argument + return TypeSyntax(argument) } /// Creates a new instance with the provided node. diff --git a/Sources/PluginCore/Variables/Property/PropertyVariable.swift b/Sources/PluginCore/Variables/Property/PropertyVariable.swift index 1a75c6e55c..b31809a7f8 100644 --- a/Sources/PluginCore/Variables/Property/PropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/PropertyVariable.swift @@ -165,7 +165,10 @@ extension PropertyVariable { gArgs.count == 1, let type = gArgs.first?.argument { - dType = type + guard let castType = TypeSyntax(type) else { + preconditionFailure("Unable to create TypeSyntax from '\(type.trimmedDescription)'") + } + dType = castType dMethod = "\(method)IfPresent" } else { dType = type From bacc1a298723d676872b2763c4ec57c122f6b4f3 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Sat, 18 Apr 2026 13:28:26 -0400 Subject: [PATCH 03/50] Add CODEOWNERS --- .github/CODEOWNERS | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..0b5e0e2d52 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,17 @@ +# Lines starting with '#' are comments. +# Each line is a file pattern followed by one or more owners. + +# More details are here: https://help.github.com/articles/about-codeowners/ + +# The '*' pattern is global owners. + +# Order is important. The last matching pattern has the most precedence. +# The folders are ordered as follows: + +# In each subsection folders are ordered first by depth, then alphabetically. +# This should make it easy to add new rules without breaking existing ones. + +# Code owners must always be teams, never individuals. + +# Global rule: +* @euc-eng/g-euc-github-continuity-admins From fae1fb2658e97d4e34229ec68026dc39e73d061b Mon Sep 17 00:00:00 2001 From: "stepsecurity-app[bot]" <188008098+stepsecurity-app[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 02:05:44 +0000 Subject: [PATCH 04/50] HV29999: [StepSecurity] Apply security best practices Signed-off-by: StepSecurity Bot --- .github/dependabot.yml | 22 ++++++++++++++++++++-- .github/workflows/main.yml | 2 +- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 67b9cc8daa..97c033b0c5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,16 +11,34 @@ updates: - package-ecosystem: github-actions directory: / schedule: - interval: monthly + interval: weekly commit-message: prefix: 'ci(Deps): ' + cooldown: + default-days: 5 + include: + - '*' + groups: + all: + applies-to: version-updates + patterns: + - '*' - package-ecosystem: npm directory: / schedule: - interval: monthly + interval: daily commit-message: prefix: 'ci(Deps): ' + cooldown: + default-days: 3 + include: + - '*' + groups: + all: + applies-to: version-updates + patterns: + - '*' - package-ecosystem: devcontainers directory: / diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fd2ae00f0b..49899b71b2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,7 +50,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - name: Spellcheck Action - uses: rojopolis/spellcheck-github-actions@0.57.0 + uses: rojopolis/spellcheck-github-actions@2b78e363a487cd137993dc2cddc17a43c89bf7a1 # 0.57.0 with: config_path: .github/config/spellcheck.yaml From da882cae1b4c1b7fb70a4e53ccb4f7d0b961066a Mon Sep 17 00:00:00 2001 From: "stepsecurity-app[bot]" <188008098+stepsecurity-app[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 03:42:57 +0000 Subject: [PATCH 05/50] HV29999: [StepSecurity] Apply security best practices Signed-off-by: StepSecurity Bot --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bc696e327a..ea89429ca6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,7 +50,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - name: Spellcheck Action - uses: rojopolis/spellcheck-github-actions@0.60.0 + uses: rojopolis/spellcheck-github-actions@e3cd8e9aec4587ec73bc0e60745aafd45c37aa2e # 0.60.0 with: config_path: .github/config/spellcheck.yaml From dc611998ca314e2282d527d92fdeece7fcc1eb10 Mon Sep 17 00:00:00 2001 From: "stepsecurity-app[bot]" <188008098+stepsecurity-app[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:38:51 +0000 Subject: [PATCH 06/50] HV29999: [StepSecurity] Apply security best practices Signed-off-by: StepSecurity Bot --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ea89429ca6..4562a9aa40 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,7 +50,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - name: Spellcheck Action - uses: rojopolis/spellcheck-github-actions@e3cd8e9aec4587ec73bc0e60745aafd45c37aa2e # 0.60.0 + uses: step-security/spellcheck-github-actions@158f059d02d7d87190bb83f9cdd99922317ce067 # v0.66.0 with: config_path: .github/config/spellcheck.yaml From 3b084e1cc6fec737cbc133947df3d832b9c9341c Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 09:18:40 -0500 Subject: [PATCH 07/50] Add strict decoding for unknown keys Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 +- README.md | 1 + Sources/MetaCodable/Codable/Codable.swift | 7 +- Sources/MetaCodable/Codable/Decodable.swift | 7 +- .../Codable/RejectUnknownKeys.swift | 58 ++ .../MetaCodable.docc/MetaCodable.md | 1 + .../Expansion/AttributeExpander.swift | 3 +- .../Expansion/Options/Options.swift | 16 + .../Variables/ComposedVariable.swift | 5 + .../Enum/Case/BasicEnumCaseVariable.swift | 15 +- .../Enum/Case/EnumCaseVariable.swift | 2 + .../AdjacentlyTaggedEnumSwitcher.swift | 4 + .../Enum/Switcher/AnyEnumSwitcher.swift | 4 + .../Enum/Switcher/EnumSwitcherVariable.swift | 11 + .../ExternallyTaggedEnumSwitcher.swift | 2 + .../Switcher/TaggedEnumSwitcherVariable.swift | 9 +- .../Enum/Switcher/UnTaggedEnumSwitcher.swift | 7 +- .../Property/AliasedPropertyVariable.swift | 8 +- .../Property/AnyPropertyVariable.swift | 4 + .../Property/Data/DecodingFallback.swift | 80 ++- .../Variables/Property/PropertyVariable.swift | 9 +- .../PropertyVariableTreeNode+CodingData.swift | 13 + ...pertyVariableTreeNode+CodingLocation.swift | 37 +- .../Tree/PropertyVariableTreeNode.swift | 34 +- .../Variables/StrictDecodingSyntax.swift | 37 ++ .../Variables/Type/ClassVariable.swift | 42 +- .../Data/CodingKeysMap/CodingKeysMap.swift | 4 + .../Variables/Type/EnumVariable.swift | 20 +- .../Variables/Type/MemberGroup.swift | 10 +- .../Variables/Type/TypeVariable.swift | 27 + .../StrictDecodingTests.swift | 494 ++++++++++++++++++ 31 files changed, 938 insertions(+), 41 deletions(-) create mode 100644 Sources/MetaCodable/Codable/RejectUnknownKeys.swift create mode 100644 Sources/PluginCore/Variables/StrictDecodingSyntax.swift create mode 100644 Tests/MetaCodableTests/StrictDecodingTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 197ea18508..c72b1f9824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +### 🚀 Features + +* added opt-in strict decoding that rejects unsupported keys with + `rejectUnknownKeys` on `Codable` and `ConformDecodable` + ## [1.6.2](https://github.com/SwiftyLab/MetaCodable/compare/v1.6.1...v1.6.2) (2026-10-23) @@ -215,4 +222,3 @@ ### 💄 Styles * add development with vscode support ([323a94a](https://github.com/SwiftyLab/MetaCodable/commit/323a94a3a1e824a2b93c1aecb51d47a90c0ec3e3)) - diff --git a/README.md b/README.md index 121a3a5b92..dae4629397 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Supercharge `Swift`'s `Codable` implementations with macros. - Allows to provide default value in case of decoding failures with ``Default(_:)``, or only in case of failures when missing value with ``Default(ifMissing:)``. Different default values can also be used for value missing and other errors respectively with ``Default(ifMissing:forErrors:)``. - Allows to create custom decoding/encoding strategies with ``HelperCoder`` and using them with ``CodedBy(_:)``, ``CodedBy(_:properties:)`` or others. i.e. ``LossySequenceCoder`` etc. - Allows applying common strategies like `ValueCoder` to all properties of a type through the ``Codable(commonStrategies:)`` parameter, reducing the need for repetitive property annotations. +- Allows opt-in rejection of unsupported decoding keys with the `rejectUnknownKeys` parameter on ``Codable(commonStrategies:rejectUnknownKeys:)`` and ``ConformDecodable(commonStrategies:rejectUnknownKeys:)``. - Allows specifying different case values with ``CodedAs(_:_:)`` and case value/protocol type identifier type different from `String` with ``CodedAs()``. - Allows specifying enum-case/protocol type identifier path with ``CodedAt(_:)`` and case content path with ``ContentAt(_:_:)``. - Allows decoding/encoding enums that lack distinct identifiers for each case data with ``UnTagged()``. diff --git a/Sources/MetaCodable/Codable/Codable.swift b/Sources/MetaCodable/Codable/Codable.swift index 56c47d5a59..fbcf3bfc42 100644 --- a/Sources/MetaCodable/Codable/Codable.swift +++ b/Sources/MetaCodable/Codable/Codable.swift @@ -50,6 +50,8 @@ /// - Parameters: /// - commonStrategies: An array of CodableCommonStrategy values specifying /// type conversion strategies to be automatically applied to all properties of the type. +/// - rejectUnknownKeys: Whether decoding should fail when a keyed container +/// contains keys that are not recognized by the generated implementation. /// /// - Important: The attached declaration must be of a `struct`, `class`, `enum` /// or `actor` type. [See the limitations for this macro](). @@ -63,7 +65,10 @@ names: named(CodingKeys), named(init(from:)), named(encode(to:)) ) @available(swift 5.9) -public macro Codable(commonStrategies: [CodableCommonStrategy] = []) = +public macro Codable( + commonStrategies: [CodableCommonStrategy] = [], + rejectUnknownKeys: Bool = false +) = #externalMacro(module: "MacroPlugin", type: "Codable") /// Indicates whether super class conforms to `Codable` or not. diff --git a/Sources/MetaCodable/Codable/Decodable.swift b/Sources/MetaCodable/Codable/Decodable.swift index 337ee6c6fb..b4da6cd7a1 100644 --- a/Sources/MetaCodable/Codable/Decodable.swift +++ b/Sources/MetaCodable/Codable/Decodable.swift @@ -44,6 +44,8 @@ /// - Parameters: /// - commonStrategies: An array of CodableCommonStrategy values specifying /// type conversion strategies to be automatically applied to all properties of the type. +/// - rejectUnknownKeys: Whether decoding should fail when a keyed container +/// contains keys that are not recognized by the generated implementation. /// /// - Important: The attached declaration must be of a `struct`, `class`, `enum` /// or `actor` type. [See the limitations for this macro](). @@ -56,5 +58,8 @@ names: named(CodingKeys), named(init(from:)) ) @available(swift 5.9) -public macro ConformDecodable(commonStrategies: [CodableCommonStrategy] = []) = +public macro ConformDecodable( + commonStrategies: [CodableCommonStrategy] = [], + rejectUnknownKeys: Bool = false +) = #externalMacro(module: "MacroPlugin", type: "ConformDecodable") diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift new file mode 100644 index 0000000000..a73f1e1f9b --- /dev/null +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Decoding support for rejecting unsupported keyed values. +public enum MetaCodableStrictDecodingRegistry { + private static let lock = NSLock() + nonisolated(unsafe) private static var keysByType: + [ObjectIdentifier: Set] = [:] + + /// Registers keys generated by a MetaCodable class. + public static func register(type: Any.Type, keys: [String]) { + lock.lock() + defer { lock.unlock() } + let identifier = ObjectIdentifier(type) + keysByType[identifier, default: []].formUnion(keys) + } + + /// Returns keys registered for a type. + static func keys(for type: Any.Type) -> Set { + lock.lock() + defer { lock.unlock() } + return keysByType[ObjectIdentifier(type)] ?? [] + } +} + +public extension Decoder { + /// Throws when the keyed value contains keys outside the provided set. + /// + /// - Parameters: + /// - allowedKeys: The string values recognized by the generated decoder + /// at this container level. + /// - type: The type being decoded, used in the error context. + /// - Throws: ``DecodingError/dataCorrupted(_:)`` when unsupported keys + /// are present or when the container cannot be inspected. + func rejectUnknownKeys( + allowedKeys: [String], + type: Any.Type + ) throws { + let rawContainer = try container( + keyedBy: DynamicCodableIdentifier.self + ) + let allowedKeys = Set(allowedKeys).union( + MetaCodableStrictDecodingRegistry.keys(for: type) + ) + let unknownKeys = rawContainer.allKeys + .map(\.stringValue) + .filter { !allowedKeys.contains($0) } + .sorted() + guard !unknownKeys.isEmpty else { return } + + let context = DecodingError.Context( + codingPath: codingPath, + debugDescription: + "Unsupported keys found while decoding \(type): " + + unknownKeys.joined(separator: ", ") + ) + throw DecodingError.dataCorrupted(context) + } +} diff --git a/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md b/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md index f2b5b7dcb4..d53d7549e5 100644 --- a/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md +++ b/Sources/MetaCodable/MetaCodable.docc/MetaCodable.md @@ -19,6 +19,7 @@ Supercharge `Swift`'s `Codable` implementations with macros. - Allows to provide default value in case of decoding failures with ``Default(_:)``, or only in case of failures when missing value with ``Default(ifMissing:)``. Different default values can also be used for value missing and other errors respectively with ``Default(ifMissing:forErrors:)``. - Allows to create custom decoding/encoding strategies with ``HelperCoder`` and using them with ``CodedBy(_:)``, ``CodedBy(_:properties:)`` or others. i.e. ``LossySequenceCoder`` etc. - Allows applying common strategies like `ValueCoder` to all properties of a type through the ``Codable(commonStrategies:)`` parameter, reducing the need for repetitive property annotations. +- Allows opt-in rejection of unsupported decoding keys with the `rejectUnknownKeys` parameter on ``Codable(commonStrategies:rejectUnknownKeys:)`` and ``ConformDecodable(commonStrategies:rejectUnknownKeys:)``. - Allows specifying different case values with ``CodedAs(_:_:)-8wdaz`` and case value/protocol type identifier type different from `String` with ``CodedAs()`` or arbitrary primitive values with ``CodedAs(_:_:)-4n3ze``. - Allows specifying enum-case/protocol type identifier path with ``CodedAt(_:)`` and case content path with ``ContentAt(_:_:)``. - Allows decoding/encoding enums that lack distinct identifiers for each case data with ``UnTagged()``. diff --git a/Sources/PluginCore/Expansion/AttributeExpander.swift b/Sources/PluginCore/Expansion/AttributeExpander.swift index a0d493659b..cf33196638 100644 --- a/Sources/PluginCore/Expansion/AttributeExpander.swift +++ b/Sources/PluginCore/Expansion/AttributeExpander.swift @@ -101,7 +101,8 @@ struct AttributeExpander { ) -> ExtensionDeclSyntax? { let method = TypeCodingLocation.Method.decode() let location = TypeCodingLocation( - method: method, conformance: `protocol` + method: method, conformance: `protocol`, + rejectUnknownKeys: options.rejectUnknownKeys ) guard let generated = variable.decoding(in: context, from: location) diff --git a/Sources/PluginCore/Expansion/Options/Options.swift b/Sources/PluginCore/Expansion/Options/Options.swift index 5b5cad3b6b..e2b74d8b6c 100644 --- a/Sources/PluginCore/Expansion/Options/Options.swift +++ b/Sources/PluginCore/Expansion/Options/Options.swift @@ -14,6 +14,8 @@ extension AttributeExpander { /// The list of `@available` attributes attached to /// original expanded declaration. let availableAttributes: [AttributeSyntax] + /// Whether generated decoding rejects unsupported keys. + let rejectUnknownKeys: Bool /// Memberwise initialization generator with provided options. /// @@ -43,6 +45,20 @@ extension AttributeExpander { return nil } } + self.rejectUnknownKeys = + decl.attributes.compactMap { attribute in + guard + case .attribute(let attr) = attribute, + let arguments = attr.arguments? + .as(LabeledExprListSyntax.self), + let argument = arguments.first(where: { + $0.label?.text == "rejectUnknownKeys" + }), + let literal = argument.expression + .as(BooleanLiteralExprSyntax.self) + else { return nil } + return literal.literal.tokenKind == .keyword(.true) + }.first ?? false } } } diff --git a/Sources/PluginCore/Variables/ComposedVariable.swift b/Sources/PluginCore/Variables/ComposedVariable.swift index 95f1aa7cba..19e61f12cb 100644 --- a/Sources/PluginCore/Variables/ComposedVariable.swift +++ b/Sources/PluginCore/Variables/ComposedVariable.swift @@ -146,6 +146,11 @@ where Self: PropertyVariable, Wrapped: PropertyVariable { func depends(on variable: Variable) -> Bool { base.depends(on: variable) } + + /// Provides additional decoding keys from the underlying variable. + var additionalDecodingKeys: [String] { + base.additionalDecodingKeys + } } extension ComposedVariable diff --git a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift index b8a80c6d4b..56c49b79e2 100644 --- a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift @@ -116,8 +116,14 @@ struct BasicEnumCaseVariable: EnumCaseVariable { let decodingKeys = codingKeys.add( keys: path.decoding, field: name, context: context ) - decodingData.register(variable: variable, keyPath: decodingKeys) - decodingNode.register(variable: variable, keyPath: decodingKeys) + decodingData.register( + variable: variable, keyPath: decodingKeys, + additionalKeys: variable.additionalDecodingKeys + ) + decodingNode.register( + variable: variable, keyPath: decodingKeys, + additionalKeys: variable.additionalDecodingKeys + ) } if variable.encode ?? true { @@ -170,7 +176,10 @@ struct BasicEnumCaseVariable: EnumCaseVariable { ) let generated = decodingNode.decoding( with: decodingData, in: context, - from: .withCoder(location.coder, keyType: codingKeys.type) + from: .withCoder( + location.coder, keyType: codingKeys.type, + rejectUnknownKeys: location.rejectUnknownKeys + ) ) let newSyntax = CodeBlockItemListSyntax { for variable in variables where variable.decode ?? true { diff --git a/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift index c38b3fe2cf..ce788d00ca 100644 --- a/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift @@ -43,6 +43,8 @@ struct EnumCaseCodingLocation { /// Represents the actual values that will be decoded. /// Only the first value will be encoded. let values: [ExprSyntax] + /// Whether generated decoding rejects unsupported keys. + let rejectUnknownKeys: Bool } /// Represents the syntax generated by `EnumCaseVariable`. diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift index 5b426e2737..b271ecaadf 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift @@ -20,6 +20,10 @@ where Wrapped: AdjacentlyTaggableSwitcher { /// This variable is used to map the nested container /// for associated variables to a pre-defined name. let variable: CoderVariable + /// Whether the wrapped switcher decodes from a keyed container. + var usesKeyedContainer: Bool { base.usesKeyedContainer } + /// The exact root keys used by the wrapped switcher when available. + var rootDecodingKeys: [String]? { base.rootDecodingKeys } /// Creates switcher variable with provided data. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift index 27f2e07316..619008d1a2 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift @@ -13,6 +13,10 @@ struct AnyEnumSwitcher: EnumSwitcherVariable { /// to its original type using type casting /// operators (`as?`, `as!`, or `as`). let base: any EnumSwitcherVariable + /// Whether the wrapped switcher decodes from a keyed container. + var usesKeyedContainer: Bool { base.usesKeyedContainer } + /// The exact root keys used by the wrapped switcher when available. + var rootDecodingKeys: [String]? { base.rootDecodingKeys } /// Provides node at which case associated variables are registered. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift index 983a791455..3c99bb244d 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift @@ -9,6 +9,10 @@ protocol EnumSwitcherVariable: Variable where CodingLocation == EnumSwitcherLocation, Generated == CodeBlockItemListSyntax { + /// Whether decoding starts from a keyed container. + var usesKeyedContainer: Bool { get } + /// The exact root keys used by the switcher when available. + var rootDecodingKeys: [String]? { get } /// Provides node at which case associated variables are registered. /// /// The returned node can be used by enum-case variables to register @@ -64,6 +68,11 @@ where } extension EnumSwitcherVariable { + /// Tagged switchers decode from keyed containers by default. + var usesKeyedContainer: Bool { true } + /// Switchers use the type coding key map by default. + var rootDecodingKeys: [String]? { nil } + /// Update provided variable data. /// /// Provided variable is returned as it is, without any data update. @@ -121,4 +130,6 @@ package struct EnumSwitcherLocation { /// Based on type of tagging in enums the default case generation for /// both decoding and encoding can be customized. let hasDefaultCase: Bool + /// Whether generated decoding rejects unsupported keys. + let rejectUnknownKeys: Bool } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift index 09b776d004..11616a1c04 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift @@ -19,6 +19,8 @@ package struct ExternallyTaggedEnumSwitcher: TaggedEnumSwitcherVariable { /// /// This encoder is passed to each case for encoding. let contentEncoder: TokenSyntax + /// The enum tag keys recognized by external tagging. + var rootDecodingKeys: [String]? { decodingKeys.allKeys } /// Provides node at which case associated variables are registered. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift index 6c461d1b91..a9511e26d5 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift @@ -52,7 +52,8 @@ extension TaggedEnumSwitcherVariable { // container. if !values.isEmpty { let cLocation = EnumCaseCodingLocation( - coder: coder, values: values + coder: coder, values: values, + rejectUnknownKeys: location.rejectUnknownKeys ) let generated = `case`.decoding( in: context, from: cLocation @@ -137,7 +138,8 @@ extension EnumSwitcherVariable { let _ = { switchable = true }() let values = value.encodeExprs.map(\.syntax) let cLocation = EnumCaseCodingLocation( - coder: coder, values: values + coder: coder, values: values, + rejectUnknownKeys: false ) let generated = `case`.encoding(in: context, to: cLocation) @@ -153,7 +155,8 @@ extension EnumSwitcherVariable { let generatedCode = generated.code.combined() SwitchCaseSyntax(label: .case(label)) { - let preSyntax = preSyntax(("\(values.first!)", !generatedCode.isEmpty)) + let preSyntax = preSyntax( + ("\(values.first!)", !generatedCode.isEmpty)) preSyntax if !generatedCode.isEmpty { generatedCode diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index fba3ed3791..26f569f478 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -5,6 +5,8 @@ import SwiftSyntaxMacros /// /// Tries decoding associated variables for each case until success. struct UnTaggedEnumSwitcher: EnumSwitcherVariable { + /// Untagged decoding tries values directly from the decoder. + var usesKeyedContainer: Bool { false } /// The node at which variables are registered. /// /// Associated variables for all cases are registered with the path @@ -129,7 +131,10 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { let `case` = cases.removeFirst() let coder = location.coder - let cLocation = EnumCaseCodingLocation(coder: coder, values: []) + let cLocation = EnumCaseCodingLocation( + coder: coder, values: [], + rejectUnknownKeys: location.rejectUnknownKeys + ) let generated = `case`.variable.decoding(in: context, from: cLocation) let nResult = decodingSyntax(for: &cases, from: location, in: context) let catchClauses = CatchClauseListSyntax { diff --git a/Sources/PluginCore/Variables/Property/AliasedPropertyVariable.swift b/Sources/PluginCore/Variables/Property/AliasedPropertyVariable.swift index 99379b7966..1125109eb9 100644 --- a/Sources/PluginCore/Variables/Property/AliasedPropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/AliasedPropertyVariable.swift @@ -20,6 +20,10 @@ where Wrapped: PropertyVariable { /// Represents all the additional `CodingKey`s that /// this variable could be encoded at. let additionalKeys: OrderedSet + /// Additional keys accepted while decoding this variable. + var additionalDecodingKeys: [String] { + additionalKeys.map(\.description) + } /// Provides the code syntax for decoding this variable /// at the provided location. @@ -39,11 +43,11 @@ where Wrapped: PropertyVariable { ) -> CodeBlockItemListSyntax { guard !additionalKeys.isEmpty, - case let .container(container, key, method) = location + case .container(let container, let key, let method) = location else { return base.decoding(in: context, from: location) } var allKeys = [key] let additionalKeys = additionalKeys.filter { aKey in - return aKey.expr.trimmedDescription != key.trimmedDescription + aKey.expr.trimmedDescription != key.trimmedDescription } allKeys.append(contentsOf: additionalKeys.map(\.expr)) let keysName: ExprSyntax = "\(CodingKeysMap.Key.name(for: name))Keys" diff --git a/Sources/PluginCore/Variables/Property/AnyPropertyVariable.swift b/Sources/PluginCore/Variables/Property/AnyPropertyVariable.swift index 2a1ff93df2..2ff3b655bd 100644 --- a/Sources/PluginCore/Variables/Property/AnyPropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/AnyPropertyVariable.swift @@ -40,6 +40,10 @@ where Initialization: VariableInitialization { /// /// Provides underlying variable value encode prefix. var encodePrefix: TokenSyntax { base.encodePrefix } + /// Provides additional decoding keys from the underlying variable. + var additionalDecodingKeys: [String] { + base.additionalDecodingKeys + } /// Whether the variable is to be decoded. /// diff --git a/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift b/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift index 453b6d54b4..8ef15cfbe4 100644 --- a/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift +++ b/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftSyntax /// Represents possible fallback options for decoding failure. @@ -41,6 +42,8 @@ package enum DecodingFallback { /// - nestedContainer: The nested container name to use if present. /// - nestedContainerHasVariables: Whether nested container has /// some variables as direct children. + /// - allowedKeys: The keys recognized by the current container, or + /// `nil` when strict validation is disabled. /// - decoding: The nested container decoding /// code block generator. /// @@ -49,6 +52,7 @@ package enum DecodingFallback { context: PropertyVariableTreeNode.CodingLocation.Context, nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, + allowedKeys: [String]?, nestedDecoding decoding: (Container) -> Generated ) -> Generated { switch context { @@ -57,6 +61,7 @@ package enum DecodingFallback { decoder: coder, keyType: kType, nestedContainer: nestedContainer, nestedContainerHasVariables: nestedContainerHasVariables, + allowedKeys: allowedKeys, nestedDecoding: decoding ) case .container(let container, let key): @@ -64,6 +69,7 @@ package enum DecodingFallback { decodingContainer: container, fromKey: key, nestedContainer: nestedContainer, nestedContainerHasVariables: nestedContainerHasVariables, + allowedKeys: allowedKeys, nestedDecoding: decoding ) } @@ -78,6 +84,8 @@ package enum DecodingFallback { /// - nestedContainer: The nested container name to use if present. /// - nestedContainerHasVariables: Whether nested container has /// some variables as direct children. + /// - allowedKeys: The keys recognized by the current container, or + /// `nil` when strict validation is disabled. /// - decoding: The nested container decoding /// code block generator. /// @@ -87,6 +95,7 @@ package enum DecodingFallback { keyType type: ExprSyntax, nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, + allowedKeys: [String]?, nestedDecoding decoding: (Container) -> Generated ) -> Generated { let isOptional: Bool @@ -103,19 +112,33 @@ package enum DecodingFallback { let cToken = nestedContainer ?? "container" let container = Container(name: cToken, isOptional: isOptional) let generated = decoding(container) + let validation = + if nestedContainer == nil { + strictDecodingValidationSyntax( + decoder: "\(decoder)" as ExprSyntax, + allowedKeys: allowedKeys + ) + } else { + CodeBlockItemListSyntax {} + } let containerSyntax = CodeBlockItemListSyntax { if nestedContainer == nil { let `try`: TokenSyntax = isOptional ? "try?" : "try" """ let \(container.name) = \(`try`) \(decoder).container(keyedBy: \(type)) """ + if !isOptional { + validation + } } generated.containerSyntax } let ifSyntax = generated.conditionalSyntax let cBinding = - if nestedContainerHasVariables && !ifSyntax.isEmpty { + if allowedKeys != nil + || nestedContainerHasVariables && !ifSyntax.isEmpty + { container.name } else { "_" as TokenSyntax @@ -128,6 +151,7 @@ package enum DecodingFallback { if let \(cBinding) = \(container.name) """ ) { + validation ifSyntax } else: { fallbacks @@ -152,6 +176,8 @@ package enum DecodingFallback { /// - nestedContainer: The nested container name to use if present. /// - nestedContainerHasVariables: Whether nested container has /// some variables as direct children. + /// - allowedKeys: The keys recognized by the current container, or + /// `nil` when strict validation is disabled. /// - decoding: The nested container decoding /// code block generator. /// @@ -161,6 +187,7 @@ package enum DecodingFallback { fromKey key: CodingKeysMap.Key, nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, + allowedKeys: [String]?, nestedDecoding decoding: (Container) -> Generated ) -> Generated { let nContainer = nestedContainer ?? "\(key.raw)_\(container.name)" @@ -172,21 +199,46 @@ package enum DecodingFallback { let generated = decoding( .init(name: nContainer, isOptional: container.isOptional) ) + let validation = + if nestedContainer == nil { + strictDecodingValidationSyntax( + decoder: + "\(container.name).superDecoder(forKey: \(key.expr))" + as ExprSyntax, + allowedKeys: allowedKeys + ) + } else { + CodeBlockItemListSyntax {} + } codingSyntax = generated.codingSyntax containerSyntax = CodeBlockItemListSyntax { if nestedContainer == nil { """ let \(nContainer) = try \(container.syntax).nestedContainer(keyedBy: \(key.type), forKey: \(key.expr)) """ + validation } generated.containerSyntax } conditionalSyntax = generated.conditionalSyntax case .onlyIfMissing(let fallbacks): let generated = decoding(.init(name: nContainer, isOptional: true)) + let validation = + if nestedContainer == nil { + strictDecodingValidationSyntax( + decoder: + "\(container.name).superDecoder(forKey: \(key.expr))" + as ExprSyntax, + allowedKeys: allowedKeys + ) + } else { + CodeBlockItemListSyntax {} + } let ifSyntax = generated.conditionalSyntax let nContainerBinding = - if nestedContainerHasVariables && !ifSyntax.isEmpty { + if allowedKeys != nil + || nestedContainerHasVariables && !ifSyntax.isEmpty + { nContainer } else { "_" as TokenSyntax @@ -207,16 +259,30 @@ package enum DecodingFallback { if let \(nContainerBinding) = \(nContainer) """ ) { + validation ifSyntax } else: { fallbacks } } - case let .ifMissing(fallbacks, ifError: eFallbacks): + case .ifMissing(let fallbacks, ifError: let eFallbacks): let generated = decoding(.init(name: nContainer, isOptional: true)) + let validation = + if nestedContainer == nil { + strictDecodingValidationSyntax( + decoder: + "\(container.name).superDecoder(forKey: \(key.expr))" + as ExprSyntax, + allowedKeys: allowedKeys + ) + } else { + CodeBlockItemListSyntax {} + } let ifSyntax = generated.conditionalSyntax let nContainerBinding = - if nestedContainerHasVariables && !ifSyntax.isEmpty { + if allowedKeys != nil + || nestedContainerHasVariables && !ifSyntax.isEmpty + { nContainer } else { "_" as TokenSyntax @@ -248,6 +314,7 @@ package enum DecodingFallback { try! IfExprSyntax( "if let \(nContainerBinding) = \(nContainer)", bodyBuilder: { + validation ifSyntax }, elseIf: IfExprSyntax("if \(containerMissing)") { @@ -305,7 +372,10 @@ extension DecodingFallback { fallbacks.append(contentsOf: rf) } return .onlyIfMissing(fallbacks) - case let (.ifMissing(lf, ifError: lef), .ifMissing(rf, ifError: ref)): + case ( + .ifMissing(let lf, ifError: let lef), + .ifMissing(let rf, ifError: let ref) + ): var fallbacks = lf if !hasEarlyExit(in: fallbacks) { fallbacks.append(contentsOf: rf) diff --git a/Sources/PluginCore/Variables/Property/PropertyVariable.swift b/Sources/PluginCore/Variables/Property/PropertyVariable.swift index eb68f3e6fd..5b3b137526 100644 --- a/Sources/PluginCore/Variables/Property/PropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/PropertyVariable.swift @@ -68,6 +68,8 @@ where /// The number of variables that this variable depends /// on to be decoded first, before decoding this variable. var dependenciesCount: UInt { get } + /// Additional keys accepted while decoding this variable. + var additionalDecodingKeys: [String] { get } /// Checks whether this variable is dependent on the provided variable. /// /// Whether provided variable needs to be decoded first, @@ -119,6 +121,9 @@ where Self: ComposedVariable, Self.Wrapped: ConditionalVariable { } extension PropertyVariable { + /// No additional decoding keys are accepted by default. + var additionalDecodingKeys: [String] { [] } + /// The arguments passed to encoding condition. /// /// Passes current variable as single argument. @@ -166,7 +171,9 @@ extension PropertyVariable { let type = gArgs.first?.argument.as(TypeSyntax.self) { guard let castType = TypeSyntax(type) else { - preconditionFailure("Unable to create TypeSyntax from '\(type.trimmedDescription)'") + preconditionFailure( + "Unable to create TypeSyntax from '\(type.trimmedDescription)'" + ) } dType = castType dMethod = "\(method)IfPresent" diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift index b5ab95813c..3ed90847fd 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift @@ -18,6 +18,8 @@ extension PropertyVariableTreeNode { var variables: [any PropertyVariable] = [] /// Nested registration node associated with keys. var children = OrderedDictionary() + /// Keys recognized by decoding at this container level. + var allowedKeys: OrderedSet = [] /// Whether the encoding container variable linked to this node /// should be declared as immutable. /// @@ -32,6 +34,8 @@ protocol VariableTreeNode { var variables: [any PropertyVariable] { get set } /// Nested registration node associated with keys. var children: OrderedDictionary { get set } + /// Keys recognized by decoding at this container level. + var allowedKeys: OrderedSet { get set } /// Whether the encoding container variable linked to this node /// should be declared as immutable. /// @@ -63,13 +67,17 @@ extension VariableTreeNode { /// additional macro metadata. /// - keyPath: The `CodingKey` path where the value /// will be decode/encoded. + /// - additionalKeys: Additional decoding keys accepted at the + /// variable's container level. /// - immutableEncodeContainer: Whether the encoding container variable /// direct parent of `variable` should be declared as immutable. mutating func register( variable: Variable, keyPath: [CodingKeysMap.Key], + additionalKeys: [String] = [], immutableEncodeContainer: Bool = false ) { if keyPath.isEmpty { + allowedKeys.append(contentsOf: additionalKeys) let depIndex = variables.firstIndex { $0.depends(on: variable) } if let index = depIndex { variables.insert(variable, at: index) @@ -80,6 +88,10 @@ extension VariableTreeNode { } let key = keyPath.first! + allowedKeys.append(key.description) + if keyPath.count == 1 { + allowedKeys.append(contentsOf: additionalKeys) + } if children[key] == nil { children[key] = .init() } @@ -95,6 +107,7 @@ extension VariableTreeNode { children[key]!.register( variable: variable, keyPath: Array(keyPath.dropFirst()), + additionalKeys: additionalKeys, immutableEncodeContainer: immutableEncodeContainer ) } diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift index a6276cd6c5..fe03058743 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift @@ -10,6 +10,10 @@ extension PropertyVariableTreeNode { struct CodingLocation: Variable { /// The context representing the nesting level. let context: Context + /// Whether generated decoding rejects unsupported keys. + let rejectUnknownKeys: Bool + /// Whether root validation is emitted after superclass decoding. + let deferRootUnknownKeyValidation: Bool /// The decoding/encoding progress. /// /// All the decoded/encoded variables and @@ -21,9 +25,18 @@ extension PropertyVariableTreeNode { /// - Parameters: /// - context: The context representing the nesting level. /// - progress: The decoding/encoding progress. - private init(context: Context, progress: Progress) { + /// - rejectUnknownKeys: Whether generated decoding rejects + /// unsupported keys. + /// - deferRootUnknownKeyValidation: Whether root validation is + /// emitted after superclass decoding. + private init( + context: Context, progress: Progress, rejectUnknownKeys: Bool, + deferRootUnknownKeyValidation: Bool + ) { self.context = context self.progress = progress + self.rejectUnknownKeys = rejectUnknownKeys + self.deferRootUnknownKeyValidation = deferRootUnknownKeyValidation } /// Add provided variable to list of already decoded/encoded variable. @@ -50,13 +63,21 @@ extension PropertyVariableTreeNode { /// - Parameters: /// - coder: The decoder/encoder for decoding/encoding. /// - keyType: The `CodingKey` type. + /// - rejectUnknownKeys: Whether generated decoding rejects + /// unsupported keys. + /// - deferRootUnknownKeyValidation: Whether root validation is + /// emitted after superclass decoding. /// /// - Returns: The new location updated with provided context. static func withCoder( - _ coder: TokenSyntax, keyType: ExprSyntax + _ coder: TokenSyntax, keyType: ExprSyntax, + rejectUnknownKeys: Bool = false, + deferRootUnknownKeyValidation: Bool = false ) -> Self { .init( - context: .coder(coder, keyType: keyType), progress: .init() + context: .coder(coder, keyType: keyType), progress: .init(), + rejectUnknownKeys: rejectUnknownKeys, + deferRootUnknownKeyValidation: deferRootUnknownKeyValidation ) } @@ -71,7 +92,9 @@ extension PropertyVariableTreeNode { _ container: Context.Container, key: CodingKeysMap.Key ) -> Self { .init( - context: .container(container, key: key), progress: progress + context: .container(container, key: key), progress: progress, + rejectUnknownKeys: rejectUnknownKeys, + deferRootUnknownKeyValidation: false ) } @@ -89,14 +112,14 @@ extension PropertyVariableTreeNode { in context: some MacroExpansionContext, from location: () = () ) -> CodeBlockItemListSyntax { let pending = progress.pending.sorted { pending1, pending2 in - return pending2.variable.depends(on: pending1.variable) + pending2.variable.depends(on: pending1.variable) } return CodeBlockItemListSyntax { for (lContext, variable) in pending { let location = lContext.forVariable let syntax = variable.decoding(in: context, from: location) switch lContext { - case let .container(container, key: _) + case .container(let container, key: _) where container.isOptional: switch variable.decodingFallback { case .onlyIfMissing(let fallbacks): @@ -109,7 +132,7 @@ extension PropertyVariableTreeNode { } else: { fallbacks } - case let .ifMissing(fallbacks, ifError: eFallbacks): + case .ifMissing(let fallbacks, ifError: let eFallbacks): try! IfExprSyntax( "if let \(container.name) = \(container.name)", bodyBuilder: { diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index 1bff26cac7..cf3e0f1a59 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -1,3 +1,4 @@ +import Foundation import OrderedCollections import SwiftSyntax import SwiftSyntaxBuilder @@ -18,6 +19,8 @@ final class PropertyVariableTreeNode: Variable, VariableTreeNode { /// Nested registration node associated with keys. var children: OrderedDictionary = [:] + /// Keys recognized by decoding at this container level. + var allowedKeys: OrderedSet = [] /// The container for decoding variables linked to this node. /// @@ -68,8 +71,33 @@ extension PropertyVariableTreeNode { ?? children.contains { _, node in node.linkedVariables.contains { $0.decode ?? true } } + let strictValidation = + location.rejectUnknownKeys + && !location.deferRootUnknownKeyValidation + let directVariables = data?.variables ?? variables + let hasDirectDecodableVariables = directVariables.contains { + $0.decode ?? true + } + let allowedKeys: [String]? = + if strictValidation { + Array(data?.allowedKeys ?? self.allowedKeys) + } else { + nil + } guard !(data?.children.isEmpty ?? children.isEmpty), childrenDecodable else { + if strictValidation, !hasDirectDecodableVariables, + case .coder(let decoder, _) = location.context + { + return .init( + containerSyntax: strictDecodingValidationSyntax( + decoder: "\(decoder)" as ExprSyntax, + allowedKeys: allowedKeys + ), + codingSyntax: decodingSyntax, + conditionalSyntax: "" + ) + } return .init( containerSyntax: "", codingSyntax: decodingSyntax, conditionalSyntax: "" @@ -90,16 +118,18 @@ extension PropertyVariableTreeNode { return variable.decodingFallback } } + .reduce(.ifMissing([], ifError: []), +) .represented( context: location.context, nestedContainer: decodingContainer, nestedContainerHasVariables: !self.children.lazy .flatMap(\.value.variables) - .filter { $0.decode ?? true }.isEmpty + .filter { $0.decode ?? true }.isEmpty, + allowedKeys: allowedKeys ) { container in self.decodingContainer = container.name let generated = decodableChildren.map { cKey, node in - return node.decoding( + node.decoding( with: data?.children[cKey], in: context, from: location.withContainer(container, key: cKey) diff --git a/Sources/PluginCore/Variables/StrictDecodingSyntax.swift b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift new file mode 100644 index 0000000000..d877292f35 --- /dev/null +++ b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift @@ -0,0 +1,37 @@ +import Foundation +import SwiftSyntax +import SwiftSyntaxBuilder + +/// Generates validation for unsupported keys at a keyed decoding container. +package func strictDecodingValidationSyntax( + decoder: ExprSyntax, + allowedKeys: [String]? +) -> CodeBlockItemListSyntax { + guard let allowedKeys else { return [] } + let keys = allowedKeys.map { + let value = + $0 + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(value)\"" + }.joined(separator: ", ") + return CodeBlockItemListSyntax { + "try \(decoder).rejectUnknownKeys(allowedKeys: [\(raw: keys)], type: Self.self)" + } +} + +/// Generates registration for class keys used by inherited strict decoding. +package func strictDecodingRegistrationSyntax( + keys: [String] +) -> CodeBlockItemListSyntax { + let values = keys.map { + let value = + $0 + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(value)\"" + }.joined(separator: ", ") + return CodeBlockItemListSyntax { + "MetaCodableStrictDecodingRegistry.register(type: Self.self, keys: [\(raw: values)])" + } +} diff --git a/Sources/PluginCore/Variables/Type/ClassVariable.swift b/Sources/PluginCore/Variables/Type/ClassVariable.swift index 95f27ac1a0..9e603bedf2 100644 --- a/Sources/PluginCore/Variables/Type/ClassVariable.swift +++ b/Sources/PluginCore/Variables/Type/ClassVariable.swift @@ -19,7 +19,7 @@ struct ClassVariable: TypeVariable, DeclaredVariable { var inherits: Inherits? { for attribute in decl.attributes { guard - case let .attribute(attribute) = attribute, + case .attribute(let attribute) = attribute, let attr = Inherits(from: attribute) else { continue } return attr @@ -129,7 +129,13 @@ struct ClassVariable: TypeVariable, DeclaredVariable { let conformance = TypeSyntax(stringLiteral: method.protocol) if location.conformance == nil, implementDecodable(location: location) { - newLocation = .init(method: method, conformance: conformance) + newLocation = .init( + method: method, conformance: conformance, + rejectUnknownKeys: location.rejectUnknownKeys, + deferRootUnknownKeyValidation: + location.rejectUnknownKeys + && (inherits?.decodable ?? true) + ) overridden = inherits?.decodable ?? true } else { newLocation = location @@ -139,13 +145,35 @@ struct ClassVariable: TypeVariable, DeclaredVariable { guard let generated = group.decoding(in: context, from: newLocation) else { return nil } - var code: CodeBlockItemListSyntax = - if overridden { + let registration = + location.rejectUnknownKeys + ? strictDecodingRegistrationSyntax( + keys: Array(group.decodingNode.allowedKeys) + ) + : CodeBlockItemListSyntax {} + var code: CodeBlockItemListSyntax + if overridden, location.rejectUnknownKeys { + code = CodeBlockItemListSyntax { + registration + generated.code "try super.\(method.name)(\(method.argLabel): \(method.arg))" - } else { - "" + strictDecodingValidationSyntax( + decoder: "\(method.arg)" as ExprSyntax, + allowedKeys: Array(group.decodingNode.allowedKeys) + ) } - code.insert(contentsOf: generated.code, at: code.startIndex) + } else { + code = + if overridden { + "try super.\(method.name)(\(method.argLabel): \(method.arg))" + } else { + "" + } + code.insert(contentsOf: generated.code, at: code.startIndex) + if location.rejectUnknownKeys { + code.insert(contentsOf: registration, at: code.startIndex) + } + } modifiers.append(contentsOf: generated.modifiers) return .init( code: code, modifiers: modifiers, diff --git a/Sources/PluginCore/Variables/Type/Data/CodingKeysMap/CodingKeysMap.swift b/Sources/PluginCore/Variables/Type/Data/CodingKeysMap/CodingKeysMap.swift index f7b17b0f18..a800ed76eb 100644 --- a/Sources/PluginCore/Variables/Type/Data/CodingKeysMap/CodingKeysMap.swift +++ b/Sources/PluginCore/Variables/Type/Data/CodingKeysMap/CodingKeysMap.swift @@ -43,6 +43,10 @@ package final class CodingKeysMap { /// in generated code syntax and must be present as case /// in generated `CodingKey` enum declaration. private var usedKeys: Set = [] + /// All raw keys registered with this map. + var allKeys: [String] { + Array(data.keys) + } /// Creates a new case-map with provided enum name. /// diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 780dde41f2..07a2381f84 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -89,7 +89,8 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { FunctionCallExprSyntax(callee: callee) { args } } else { FunctionCallExprSyntax( - calledExpression: callee, leftParen: nil, rightParen: nil + calledExpression: callee, leftParen: nil, + rightParen: nil ) {} } return ExprSyntax(fExpr) @@ -274,9 +275,19 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { coder: location.method.arg, container: Self.container, keyType: codingKeys.type, selfType: selfType, selfValue: "_", cases: cases, codeExpr: caseDecodeExpr, - hasDefaultCase: forceDefault + hasDefaultCase: forceDefault, + rejectUnknownKeys: location.rejectUnknownKeys ) code = CodeBlockItemListSyntax { + if location.rejectUnknownKeys, switcher.usesKeyedContainer { + let keys = + switcher.rootDecodingKeys + ?? codingKeys.allKeys + strictDecodingValidationSyntax( + decoder: "\(location.method.arg)" as ExprSyntax, + allowedKeys: keys + ) + } switcher.decoding(in: context, from: switcherLoc) } } else { @@ -328,7 +339,8 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { coder: location.method.arg, container: Self.container, keyType: codingKeys.type, selfType: selfType, selfValue: expr, cases: cases, codeExpr: caseEncodeExpr, - hasDefaultCase: forceDefault + hasDefaultCase: forceDefault, + rejectUnknownKeys: false ) code = CodeBlockItemListSyntax { switcher.encoding(in: context, to: switcherLocation) @@ -525,7 +537,7 @@ extension EnumVariable { case (.bool, .bool), (.int, .int), (.double, .double), (.string, .string): return true - case let (.unknown(lhsType), .unknown(rhsType)): + case (.unknown(let lhsType), .unknown(let rhsType)): return lhsType.trimmedDescription == rhsType.trimmedDescription default: diff --git a/Sources/PluginCore/Variables/Type/MemberGroup.swift b/Sources/PluginCore/Variables/Type/MemberGroup.swift index c109f105a2..e14cf7fdc8 100644 --- a/Sources/PluginCore/Variables/Type/MemberGroup.swift +++ b/Sources/PluginCore/Variables/Type/MemberGroup.swift @@ -61,7 +61,10 @@ where if variable.decode ?? true { let keys = codingKeys.add( keys: decodingPath, field: name, context: context) - decodingNode.register(variable: variable, keyPath: keys) + decodingNode.register( + variable: variable, keyPath: keys, + additionalKeys: variable.additionalDecodingKeys + ) } if variable.encode ?? true { @@ -92,7 +95,10 @@ where guard let conformance = location.conformance else { return nil } let syntax = CodeBlockItemListSyntax { let nLocation = PropertyVariableTreeNode.CodingLocation.withCoder( - location.method.arg, keyType: codingKeys.type + location.method.arg, keyType: codingKeys.type, + rejectUnknownKeys: location.rejectUnknownKeys, + deferRootUnknownKeyValidation: + location.deferRootUnknownKeyValidation ) decodingNode.decoding(in: context, from: nLocation).combined() nLocation.decoding(in: context) diff --git a/Sources/PluginCore/Variables/Type/TypeVariable.swift b/Sources/PluginCore/Variables/Type/TypeVariable.swift index 89196b4746..d26a631013 100644 --- a/Sources/PluginCore/Variables/Type/TypeVariable.swift +++ b/Sources/PluginCore/Variables/Type/TypeVariable.swift @@ -150,6 +150,10 @@ package struct TypeCodingLocation { /// Can be `Decodable`/`Encodable` type /// or `null` if type already has conformance. let conformance: TypeSyntax? + /// Whether generated decoding rejects unsupported keys. + let rejectUnknownKeys: Bool + /// Whether root validation is emitted after superclass decoding. + let deferRootUnknownKeyValidation: Bool /// Creates type decoding/encoding data from provided data. /// @@ -157,8 +161,31 @@ package struct TypeCodingLocation { /// - method: The method data. /// - conformance: The conformed type. package init(method: Method, conformance: TypeSyntax?) { + self.init( + method: method, conformance: conformance, + rejectUnknownKeys: false, + deferRootUnknownKeyValidation: false + ) + } + + /// Creates type coding location with decoding strictness. + /// + /// - Parameters: + /// - method: The method data. + /// - conformance: The conformed type. + /// - rejectUnknownKeys: Whether generated decoding rejects unsupported + /// keys. + /// - deferRootUnknownKeyValidation: Whether root validation is emitted + /// after superclass decoding. + package init( + method: Method, conformance: TypeSyntax?, + rejectUnknownKeys: Bool, + deferRootUnknownKeyValidation: Bool = false + ) { self.method = method self.conformance = conformance + self.rejectUnknownKeys = rejectUnknownKeys + self.deferRootUnknownKeyValidation = deferRootUnknownKeyValidation } } diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift new file mode 100644 index 0000000000..8a7b4c99e7 --- /dev/null +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -0,0 +1,494 @@ +import Foundation +import HelperCoders +import MetaCodable +import Testing + +@testable import PluginCore + +struct StrictDecodingTests { + @Codable(rejectUnknownKeys: true) + struct StrictRecord: Equatable { + let name: String + let count: Int + } + + @Codable + struct PermissiveRecord { + let name: String + } + + @ConformDecodable(rejectUnknownKeys: true) + struct StrictDecodableRecord { + let value: String + } + + @ConformEncodable + struct StrictEncodableRecord { + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictNestedRecord { + @CodedIn("payload") + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictRenamedRecord { + @CodedAt("wire_value") + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictDirectRecord { + @CodedAt + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictDecodedAtRecord { + @DecodedAt("payload", "wire_value") + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictAliasRecord { + @CodedAs("legacy_value") + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictDefaultNestedRecord { + @Default("fallback") + @CodedIn("payload") + let value: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictIgnoredRecord { + let value: String + @IgnoreDecoding + var ignored: String = "default" + } + + @Codable( + commonStrategies: [.codedBy(.valueCoder())], + rejectUnknownKeys: true + ) + struct StrictStrategyRecord { + let value: Int + } + + @Codable(rejectUnknownKeys: true) + struct StrictArrayRecord { + let values: [StrictDecodingTests.StrictRecord] + } + + @Codable(rejectUnknownKeys: true) + struct StrictEmptyRecord {} + + @Codable(rejectUnknownKeys: true) + struct StrictClass { + var value: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictBase { + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictChild: StrictBase { + var child: String = "" + + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + actor StrictActor { + let value: String + } + + @Codable(rejectUnknownKeys: true) + enum StrictExternalCommand: Equatable { + case load(key: String) + case store(key: String, value: Int) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictInternalCommand: Equatable { + case load(key: String) + case store(key: String, value: Int) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + @ContentAt("content") + enum StrictAdjacentCommand: Equatable { + case load(key: String) + case store(key: String, value: Int) + } + + @Test + func strictExpansionAddsValidation() throws { + assertMacroExpansion( + """ + @Codable(rejectUnknownKeys: true) + struct Model { + let value: String + } + """, + expandedSource: + """ + struct Model { + let value: String + } + + extension Model: Decodable { + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try decoder.rejectUnknownKeys(allowedKeys: ["value"], type: Self.self) + self.value = try container.decode(String.self, forKey: CodingKeys.value) + } + } + + extension Model: Encodable { + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.value, forKey: CodingKeys.value) + } + } + + extension Model { + enum CodingKeys: String, CodingKey { + case value = "value" + } + } + """ + ) + } + + @Test + func strictRecordRejectsUnknownKeysAndReportsAllKeys() throws { + try expectDataCorrupted( + StrictRecord.self, + json: """ + { + "name": "value", + "count": 42, + "first_extra": true, + "second_extra": false + } + """, + containing: ["first_extra", "second_extra"] + ) + } + + @Test + func strictRecordDecodesKnownKeys() throws { + let data = Data( + """ + { + "name": "value", + "count": 42 + } + """.utf8 + ) + let decoded = try JSONDecoder().decode(StrictRecord.self, from: data) + #expect(decoded == StrictRecord(name: "value", count: 42)) + } + + @Test + func conformDecodableRejectsUnknownKeys() throws { + try expectDataCorrupted( + StrictDecodableRecord.self, + json: """ + { + "value": "decoded", + "extra": true + } + """, + containing: ["extra"] + ) + } + + @Test + func permissiveDecodingRemainsUnchanged() throws { + let data = Data( + """ + { + "name": "value", + "extra": true + } + """.utf8 + ) + let decoded = try JSONDecoder().decode( + PermissiveRecord.self, from: data + ) + #expect(decoded.name == "value") + } + + @Test + func nestedContainersRejectUnknownKeysAtTheirOwnLevel() throws { + let validData = Data( + """ + { + "payload": { + "value": "decoded" + } + } + """.utf8 + ) + let valid = try JSONDecoder().decode( + StrictNestedRecord.self, from: validData + ) + #expect(valid.value == "decoded") + + try expectDataCorrupted( + StrictNestedRecord.self, + json: """ + { + "payload": { + "value": "decoded", + "extra": true + } + } + """, + containing: ["extra"] + ) + } + + @Test + func renamedAndAliasKeysRemainSupported() throws { + let renamedData = Data(#"{"wire_value":"renamed"}"#.utf8) + let renamed = try JSONDecoder().decode( + StrictRenamedRecord.self, from: renamedData + ) + #expect(renamed.value == "renamed") + + let aliasData = Data(#"{"legacy_value":"aliased"}"#.utf8) + let alias = try JSONDecoder().decode( + StrictAliasRecord.self, from: aliasData + ) + #expect(alias.value == "aliased") + + try expectDataCorrupted( + StrictAliasRecord.self, + json: #"{"legacy_value":"aliased","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictModeDoesNotRequireKeyedContainersForDirectValues() throws { + let data = Data(#""direct_value""#.utf8) + let decoded = try JSONDecoder().decode( + StrictDirectRecord.self, from: data + ) + #expect(decoded.value == "direct_value") + } + + @Test + func decodedAtNestedKeysRejectUnknownKeys() throws { + let validData = Data( + #"{"payload":{"wire_value":"decoded"}}"#.utf8 + ) + let valid = try JSONDecoder().decode( + StrictDecodedAtRecord.self, from: validData + ) + #expect(valid.value == "decoded") + + try expectDataCorrupted( + StrictDecodedAtRecord.self, + json: #"{"payload":{"wire_value":"decoded","extra":true}}"#, + containing: ["extra"] + ) + } + + @Test + func strictValidationIsNotSwallowedByDefaultFallbacks() throws { + let missingData = Data(#"{}"#.utf8) + let missing = try JSONDecoder().decode( + StrictDefaultNestedRecord.self, from: missingData + ) + #expect(missing.value == "fallback") + + try expectDataCorrupted( + StrictDefaultNestedRecord.self, + json: #"{"payload":{"extra":true}}"#, + containing: ["extra"] + ) + } + + @Test + func ignoredKeysAreUnsupported() throws { + try expectDataCorrupted( + StrictIgnoredRecord.self, + json: #"{"value":"decoded","ignored":"input"}"#, + containing: ["ignored"] + ) + } + + @Test + func commonStrategiesDoNotDisableStrictValidation() throws { + let validData = Data(#"{"value":"42"}"#.utf8) + let valid = try JSONDecoder().decode( + StrictStrategyRecord.self, from: validData + ) + #expect(valid.value == 42) + + try expectDataCorrupted( + StrictStrategyRecord.self, + json: #"{"value":"42","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func nestedStrictTypesRejectUnknownKeysInsideCollections() throws { + try expectDataCorrupted( + StrictArrayRecord.self, + json: #"{"values":[{"name":"value","count":1,"extra":true}]}"#, + containing: ["extra"] + ) + } + + @Test + func emptyStrictRecordsRejectPresentKeys() throws { + let validData = Data(#"{}"#.utf8) + _ = try JSONDecoder().decode(StrictEmptyRecord.self, from: validData) + + try expectDataCorrupted( + StrictEmptyRecord.self, + json: #"{"extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictClassesRejectUnknownKeys() throws { + try expectDataCorrupted( + StrictClass.self, + json: #"{"value":"decoded","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictClassInheritanceAcceptsTheCombinedKeySet() throws { + let data = Data( + #"{"base":"base","child":"child"}"#.utf8 + ) + let decoded = try JSONDecoder().decode(StrictChild.self, from: data) + #expect(decoded.base == "base") + #expect(decoded.child == "child") + + try expectDataCorrupted( + StrictChild.self, + json: #"{"base":"base","child":"child","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictExternalEnumsRejectUnknownKeys() throws { + let data = try encodedData( + StrictExternalCommand.load(key: "key"), + addingRootKey: "extra" + ) + try expectDataCorrupted( + StrictExternalCommand.self, + data: data, + containing: ["extra"] + ) + } + + @Test + func strictInternalEnumsRejectUnknownKeys() throws { + let data = try encodedData( + StrictInternalCommand.load(key: "key"), + addingRootKey: "extra" + ) + try expectDataCorrupted( + StrictInternalCommand.self, + data: data, + containing: ["extra"] + ) + } + + @Test + func strictAdjacentEnumsRejectUnknownContentKeys() throws { + let encoded = try JSONEncoder().encode( + StrictAdjacentCommand.load(key: "key") + ) + var object = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + var content = try #require(object["content"] as? [String: Any]) + content["extra"] = true + object["content"] = content + let data = try JSONSerialization.data(withJSONObject: object) + + try expectDataCorrupted( + StrictAdjacentCommand.self, + data: data, + containing: ["extra"] + ) + } + + @Test + func actorStrictMacroExpansionCompiles() throws { + _ = StrictActor.self + } + + private func expectDataCorrupted( + _ type: T.Type, + json: String, + containing keys: [String] + ) throws { + try expectDataCorrupted( + type, + data: Data(json.utf8), + containing: keys + ) + } + + private func expectDataCorrupted( + _ type: T.Type, + data: Data, + containing keys: [String] + ) throws { + do { + _ = try JSONDecoder().decode(type, from: data) + Issue.record("Expected decoding to reject unsupported keys") + } catch let DecodingError.dataCorrupted(context) { + for key in keys { + #expect(context.debugDescription.contains(key)) + } + } catch { + Issue.record("Expected dataCorrupted, got \(error)") + } + } + + private func encodedData( + _ value: T, + addingRootKey key: String + ) throws -> Data { + var object = try #require( + JSONSerialization.jsonObject( + with: JSONEncoder().encode(value) + ) as? [String: Any] + ) + object[key] = true + return try JSONSerialization.data(withJSONObject: object) + } +} From 80a9bdb05cf91234de70f16aea96ce89d3cc5ae7 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 09:49:26 -0500 Subject: [PATCH 08/50] Fix strict decoding review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 318 ++++++++++++++++-- .../Variables/ComposedVariable.swift | 4 + .../Enum/Case/BasicEnumCaseVariable.swift | 7 +- .../Enum/Case/EnumCaseVariable.swift | 7 + .../Switcher/AdjacentlyTaggableSwitcher.swift | 11 +- .../AdjacentlyTaggedEnumSwitcher.swift | 4 + .../Enum/Switcher/AnyEnumSwitcher.swift | 4 + .../Enum/Switcher/EnumSwitcherVariable.swift | 8 + .../InternallyTaggedEnumSwitcher.swift | 6 + .../Switcher/TaggedEnumSwitcherVariable.swift | 7 +- .../Enum/Switcher/UnTaggedEnumSwitcher.swift | 11 +- .../PropertyVariableTreeNode+CodingData.swift | 17 + ...pertyVariableTreeNode+CodingLocation.swift | 24 +- .../Tree/PropertyVariableTreeNode.swift | 9 +- .../Variables/StrictDecodingSyntax.swift | 27 +- .../Variables/Type/ClassVariable.swift | 36 +- .../Variables/Type/EnumVariable.swift | 23 +- .../Variables/Type/MemberGroup.swift | 4 +- .../Variables/Type/TypeVariable.swift | 12 +- .../StrictDecodingTests.swift | 130 +++++++ 20 files changed, 581 insertions(+), 88 deletions(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index a73f1e1f9b..b85a73eac8 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -1,24 +1,230 @@ import Foundation -/// Decoding support for rejecting unsupported keyed values. -public enum MetaCodableStrictDecodingRegistry { - private static let lock = NSLock() - nonisolated(unsafe) private static var keysByType: - [ObjectIdentifier: Set] = [:] - - /// Registers keys generated by a MetaCodable class. - public static func register(type: Any.Type, keys: [String]) { - lock.lock() - defer { lock.unlock() } - let identifier = ObjectIdentifier(type) - keysByType[identifier, default: []].formUnion(keys) - } - - /// Returns keys registered for a type. - static func keys(for type: Any.Type) -> Set { - lock.lock() - defer { lock.unlock() } - return keysByType[ObjectIdentifier(type)] ?? [] +/// A decoder wrapper that carries inherited strict-decoding key metadata. +public struct MetaCodableStrictDecoder: Decoder { + private let base: any Decoder + fileprivate let state: State + + /// Creates a decoder wrapper with additional keys accepted at each path. + /// + /// - Parameters: + /// - decoder: The decoder to wrap. + /// - allowedKeysByPath: Additional accepted keys keyed by dot-separated + /// coding paths. The empty string represents the root container. + public init( + decoder: any Decoder, + allowedKeysByPath: [String: [String]] + ) { + self.base = decoder + self.state = .init(allowedKeysByPath: allowedKeysByPath) + } + + fileprivate init(base: any Decoder, state: State) { + self.base = base + self.state = state + } + + /// The coding path of the wrapped decoder. + public var codingPath: [any CodingKey] { base.codingPath } + /// The user information of the wrapped decoder. + public var userInfo: [CodingUserInfoKey: Any] { base.userInfo } + + /// Creates a keyed container that preserves inherited key metadata. + public func container( + keyedBy type: Key.Type + ) throws -> KeyedDecodingContainer { + let container = try base.container(keyedBy: type) + return KeyedDecodingContainer( + StrictKeyedDecodingContainer(base: container, state: state) + ) + } + + /// Creates an unkeyed container from the wrapped decoder. + public func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + try base.unkeyedContainer() + } + + /// Creates a single-value container from the wrapped decoder. + public func singleValueContainer() throws + -> any SingleValueDecodingContainer + { + try base.singleValueContainer() + } + + fileprivate func additionalKeys(at path: [any CodingKey]) -> Set { + state.additionalKeys(at: path) + } + + fileprivate final class State { + let allowedKeysByPath: [String: Set] + var observedKeysByPath: [String: Set] = [:] + + init(allowedKeysByPath: [String: [String]]) { + self.allowedKeysByPath = allowedKeysByPath.mapValues(Set.init) + } + + func record(key: any CodingKey, at path: [any CodingKey]) { + let path = Self.path(for: path) + observedKeysByPath[path, default: []].insert(key.stringValue) + } + + func additionalKeys(at path: [any CodingKey]) -> Set { + let path = Self.path(for: path) + return (allowedKeysByPath[path] ?? []).union( + observedKeysByPath[path] ?? [] + ) + } + + static func path(for codingPath: [any CodingKey]) -> String { + codingPath.map(\.stringValue).joined(separator: ".") + } + } +} + +private struct StrictKeyedDecodingContainer: + KeyedDecodingContainerProtocol +{ + let base: KeyedDecodingContainer + let state: MetaCodableStrictDecoder.State + + var codingPath: [any CodingKey] { base.codingPath } + var allKeys: [Key] { base.allKeys } + + func contains(_ key: Key) -> Bool { + state.record(key: key, at: codingPath) + return base.contains(key) + } + + func decodeNil(forKey key: Key) throws -> Bool { + state.record(key: key, at: codingPath) + return try base.decodeNil(forKey: key) + } + + func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: String.Type, forKey key: Key) throws -> String { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + #if swift(>=6) + @available( + macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, * + ) + func decode(_ type: Int128.Type, forKey key: Key) throws -> Int128 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + @available( + macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, * + ) + func decode(_ type: UInt128.Type, forKey key: Key) throws -> UInt128 { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + #endif + + func decode( + _ type: T.Type, + forKey key: Key + ) throws -> T { + state.record(key: key, at: codingPath) + return try base.decode(type, forKey: key) + } + + func nestedContainer( + keyedBy type: NestedKey.Type, + forKey key: Key + ) throws -> KeyedDecodingContainer { + state.record(key: key, at: codingPath) + let nested = try base.nestedContainer(keyedBy: type, forKey: key) + return KeyedDecodingContainer( + StrictKeyedDecodingContainer(base: nested, state: state) + ) + } + + func nestedUnkeyedContainer( + forKey key: Key + ) throws -> any UnkeyedDecodingContainer { + state.record(key: key, at: codingPath) + return try base.nestedUnkeyedContainer(forKey: key) + } + + func superDecoder() throws -> any Decoder { + MetaCodableStrictDecoder( + base: try base.superDecoder(), state: state + ) + } + + func superDecoder(forKey key: Key) throws -> any Decoder { + state.record(key: key, at: codingPath) + return MetaCodableStrictDecoder( + base: try base.superDecoder(forKey: key), state: state + ) } } @@ -27,10 +233,10 @@ public extension Decoder { /// /// - Parameters: /// - allowedKeys: The string values recognized by the generated decoder - /// at this container level. + /// at this container level. /// - type: The type being decoded, used in the error context. /// - Throws: ``DecodingError/dataCorrupted(_:)`` when unsupported keys - /// are present or when the container cannot be inspected. + /// are present or when the container cannot be inspected. func rejectUnknownKeys( allowedKeys: [String], type: Any.Type @@ -38,9 +244,11 @@ public extension Decoder { let rawContainer = try container( keyedBy: DynamicCodableIdentifier.self ) - let allowedKeys = Set(allowedKeys).union( - MetaCodableStrictDecodingRegistry.keys(for: type) - ) + let inheritedKeys = + (self as? MetaCodableStrictDecoder)?.additionalKeys( + at: codingPath + ) ?? [] + let allowedKeys = Set(allowedKeys).union(inheritedKeys) let unknownKeys = rawContainer.allKeys .map(\.stringValue) .filter { !allowedKeys.contains($0) } @@ -55,4 +263,66 @@ public extension Decoder { ) throw DecodingError.dataCorrupted(context) } + + /// Validates all keyed paths generated for a strict inherited class. + func rejectUnknownKeys( + allowedKeysByPath: [String: [String]], + type: Any.Type + ) throws { + let rawContainer = try container( + keyedBy: DynamicCodableIdentifier.self + ) + try rawContainer.rejectUnknownKeys( + atPath: "", + allowedKeysByPath: allowedKeysByPath, + type: type, + state: (self as? MetaCodableStrictDecoder)?.state + ) + } +} + +private extension KeyedDecodingContainer +where Key == DynamicCodableIdentifier { + func rejectUnknownKeys( + atPath path: String, + allowedKeysByPath: [String: [String]], + type: Any.Type, + state: MetaCodableStrictDecoder.State? + ) throws { + let inheritedKeys = + state?.additionalKeys(at: codingPath) ?? [] + let allowedKeys = Set(allowedKeysByPath[path] ?? []) + .union(inheritedKeys) + let unknownKeys = + allKeys + .map(\.stringValue) + .filter { !allowedKeys.contains($0) } + .sorted() + guard unknownKeys.isEmpty else { + let context = DecodingError.Context( + codingPath: codingPath, + debugDescription: + "Unsupported keys found while decoding \(type): " + + unknownKeys.joined(separator: ", ") + ) + throw DecodingError.dataCorrupted(context) + } + + for key in allKeys { + let childPath = + path.isEmpty ? key.stringValue : "\(path).\(key.stringValue)" + guard allowedKeysByPath[childPath] != nil, + let nested = try? nestedContainer( + keyedBy: DynamicCodableIdentifier.self, + forKey: key + ) + else { continue } + try nested.rejectUnknownKeys( + atPath: childPath, + allowedKeysByPath: allowedKeysByPath, + type: type, + state: state + ) + } + } } diff --git a/Sources/PluginCore/Variables/ComposedVariable.swift b/Sources/PluginCore/Variables/ComposedVariable.swift index 19e61f12cb..cb0f860e7e 100644 --- a/Sources/PluginCore/Variables/ComposedVariable.swift +++ b/Sources/PluginCore/Variables/ComposedVariable.swift @@ -185,6 +185,10 @@ where Self: EnumCaseVariable, Wrapped: EnumCaseVariable { /// /// Provides associated variables of the underlying variable value. var variables: [any AssociatedVariable] { base.variables } + /// Provides decoding keys from the underlying enum case. + var decodingAllowedKeys: [String] { + base.decodingAllowedKeys + } } extension ComposedVariable where Self: TypeVariable, Wrapped: TypeVariable { diff --git a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift index 56c49b79e2..35cfaf6fed 100644 --- a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift @@ -65,6 +65,10 @@ struct BasicEnumCaseVariable: EnumCaseVariable { /// /// It generates keys for associated variables decoding/encoding. let codingKeys: CodingKeysMap + /// Keys recognized by decoding associated values at the current level. + var decodingAllowedKeys: [String] { + Array(decodingNode.allowedKeys) + } /// Creates a new enum-case variable from provided data. /// @@ -178,7 +182,8 @@ struct BasicEnumCaseVariable: EnumCaseVariable { with: decodingData, in: context, from: .withCoder( location.coder, keyType: codingKeys.type, - rejectUnknownKeys: location.rejectUnknownKeys + rejectUnknownKeys: location.rejectUnknownKeys, + additionalAllowedKeys: location.additionalAllowedKeys ) ) let newSyntax = CodeBlockItemListSyntax { diff --git a/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift index ce788d00ca..f04ecb16cd 100644 --- a/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift @@ -12,9 +12,14 @@ where CodingLocation == EnumCaseCodingLocation, Generated == EnumCaseGenerated { /// Represents all the associated variable data available in this /// enum-case declaration data. var variables: [any AssociatedVariable] { get } + /// Keys recognized by decoding associated values at the current level. + var decodingAllowedKeys: [String] { get } } extension EnumCaseVariable { + /// No associated decoding keys are exposed by default. + var decodingAllowedKeys: [String] { [] } + /// The arguments passed to encoding condition. /// /// All associated variables are passed as argument. @@ -45,6 +50,8 @@ struct EnumCaseCodingLocation { let values: [ExprSyntax] /// Whether generated decoding rejects unsupported keys. let rejectUnknownKeys: Bool + /// Additional keys accepted by the current decoding container. + let additionalAllowedKeys: [String] } /// Represents the syntax generated by `EnumCaseVariable`. diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift index d6806f1d8b..519f2c13fa 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift @@ -240,11 +240,15 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { "var \(container): \(containerType)" decodingNode.decoding( in: context, - from: .withCoder(coder, keyType: location.keyType) + from: .withCoder( + coder, keyType: location.keyType, + rejectUnknownKeys: location.rejectUnknownKeys + ) ).combined() if containerType.isOptionalTypeSyntax { - let needsContainer = location.cases.contains { variable, _ in + let needsContainer = location.cases.contains { + variable, _ in variable.variables.contains { $0.label != nil } } let topContainerOptional = decodingNode.children @@ -259,7 +263,8 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { } let header: SyntaxNodeString = - needsContainer && topContainerOptional && !rawRepresentable + needsContainer && topContainerOptional + && !rawRepresentable ? "if let \(container) = \(container), let \(location.container) = \(location.container)" : "if let \(container) = \(container)" try! IfExprSyntax(header) { diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift index b271ecaadf..b38d431b75 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift @@ -24,6 +24,10 @@ where Wrapped: AdjacentlyTaggableSwitcher { var usesKeyedContainer: Bool { base.usesKeyedContainer } /// The exact root keys used by the wrapped switcher when available. var rootDecodingKeys: [String]? { base.rootDecodingKeys } + /// Adjacent case values are decoded from the content container. + var caseDecodingSharesRootContainer: Bool { + base.caseDecodingSharesRootContainer + } /// Creates switcher variable with provided data. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift index 619008d1a2..b042f71000 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift @@ -17,6 +17,10 @@ struct AnyEnumSwitcher: EnumSwitcherVariable { var usesKeyedContainer: Bool { base.usesKeyedContainer } /// The exact root keys used by the wrapped switcher when available. var rootDecodingKeys: [String]? { base.rootDecodingKeys } + /// Whether case values share the wrapped switcher's root container. + var caseDecodingSharesRootContainer: Bool { + base.caseDecodingSharesRootContainer + } /// Provides node at which case associated variables are registered. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift index 3c99bb244d..f387f4799e 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift @@ -13,6 +13,8 @@ where var usesKeyedContainer: Bool { get } /// The exact root keys used by the switcher when available. var rootDecodingKeys: [String]? { get } + /// Whether case values are decoded in the same root container. + var caseDecodingSharesRootContainer: Bool { get } /// Provides node at which case associated variables are registered. /// /// The returned node can be used by enum-case variables to register @@ -72,6 +74,8 @@ extension EnumSwitcherVariable { var usesKeyedContainer: Bool { true } /// Switchers use the type coding key map by default. var rootDecodingKeys: [String]? { nil } + /// Case values are nested unless a switcher opts into sharing the root. + var caseDecodingSharesRootContainer: Bool { false } /// Update provided variable data. /// @@ -132,4 +136,8 @@ package struct EnumSwitcherLocation { let hasDefaultCase: Bool /// Whether generated decoding rejects unsupported keys. let rejectUnknownKeys: Bool + /// Keys accepted at the enum root container. + let rootDecodingKeys: [String] + /// Whether associated case values share the enum root container. + let caseDecodingSharesRootContainer: Bool } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift index cf8e940921..38dd86513a 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift @@ -12,6 +12,12 @@ where Variable: PropertyVariable, Variable.Initialization == RequiredInitialization { + /// Whether this enum has a keyed discriminator container. + var usesKeyedContainer: Bool { !keyPath.decoding.isEmpty } + /// The discriminator keys used at the enum root. + var rootDecodingKeys: [String]? { Array(decodingNode.allowedKeys) } + /// Internally tagged case values share the discriminator container. + var caseDecodingSharesRootContainer: Bool { true } /// The identifier variable build action type. /// /// Used to build the identifier data and pass in encoding callback. diff --git a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift index a9511e26d5..755a3d1862 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift @@ -53,7 +53,10 @@ extension TaggedEnumSwitcherVariable { if !values.isEmpty { let cLocation = EnumCaseCodingLocation( coder: coder, values: values, - rejectUnknownKeys: location.rejectUnknownKeys + rejectUnknownKeys: location.rejectUnknownKeys, + additionalAllowedKeys: + location.caseDecodingSharesRootContainer + ? location.rootDecodingKeys : [] ) let generated = `case`.decoding( in: context, from: cLocation @@ -139,7 +142,7 @@ extension EnumSwitcherVariable { let values = value.encodeExprs.map(\.syntax) let cLocation = EnumCaseCodingLocation( coder: coder, values: values, - rejectUnknownKeys: false + rejectUnknownKeys: false, additionalAllowedKeys: [] ) let generated = `case`.encoding(in: context, to: cLocation) diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index 26f569f478..d7ddb87d85 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -5,8 +5,12 @@ import SwiftSyntaxMacros /// /// Tries decoding associated variables for each case until success. struct UnTaggedEnumSwitcher: EnumSwitcherVariable { - /// Untagged decoding tries values directly from the decoder. - var usesKeyedContainer: Bool { false } + /// Untagged decoding uses a keyed container when cases have keyed values. + var usesKeyedContainer: Bool { !node.allowedKeys.isEmpty } + /// The keys shared by keyed untagged cases. + var rootDecodingKeys: [String]? { Array(node.allowedKeys) } + /// Untagged case values share their direct decoder. + var caseDecodingSharesRootContainer: Bool { true } /// The node at which variables are registered. /// /// Associated variables for all cases are registered with the path @@ -133,7 +137,8 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { let coder = location.coder let cLocation = EnumCaseCodingLocation( coder: coder, values: [], - rejectUnknownKeys: location.rejectUnknownKeys + rejectUnknownKeys: location.rejectUnknownKeys, + additionalAllowedKeys: [] ) let generated = `case`.variable.decoding(in: context, from: cLocation) let nResult = decodingSyntax(for: &cases, from: location, in: context) diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift index 3ed90847fd..a4d2177949 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift @@ -49,6 +49,23 @@ protocol VariableTreeNode { } extension VariableTreeNode { + /// Returns decoding keys grouped by their container path. + func allowedKeysByPath( + prefix: [String] = [] + ) -> [String: [String]] { + var result = [ + prefix.joined(separator: "."): Array(allowedKeys) + ] + for (key, child) in children { + result.merge( + child.allowedKeysByPath( + prefix: prefix + [key.description] + ) + ) { _, new in new } + } + return result + } + /// List of all the linked variables registered. /// /// Gets all variables at current node diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift index fe03058743..1432e12418 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift @@ -12,8 +12,10 @@ extension PropertyVariableTreeNode { let context: Context /// Whether generated decoding rejects unsupported keys. let rejectUnknownKeys: Bool - /// Whether root validation is emitted after superclass decoding. - let deferRootUnknownKeyValidation: Bool + /// Whether strict validation is emitted after superclass decoding. + let deferUnknownKeyValidation: Bool + /// Additional keys accepted by the current decoding container. + let additionalAllowedKeys: [String] /// The decoding/encoding progress. /// /// All the decoded/encoded variables and @@ -27,16 +29,17 @@ extension PropertyVariableTreeNode { /// - progress: The decoding/encoding progress. /// - rejectUnknownKeys: Whether generated decoding rejects /// unsupported keys. - /// - deferRootUnknownKeyValidation: Whether root validation is + /// - deferUnknownKeyValidation: Whether strict validation is /// emitted after superclass decoding. private init( context: Context, progress: Progress, rejectUnknownKeys: Bool, - deferRootUnknownKeyValidation: Bool + deferUnknownKeyValidation: Bool, additionalAllowedKeys: [String] ) { self.context = context self.progress = progress self.rejectUnknownKeys = rejectUnknownKeys - self.deferRootUnknownKeyValidation = deferRootUnknownKeyValidation + self.deferUnknownKeyValidation = deferUnknownKeyValidation + self.additionalAllowedKeys = additionalAllowedKeys } /// Add provided variable to list of already decoded/encoded variable. @@ -65,19 +68,21 @@ extension PropertyVariableTreeNode { /// - keyType: The `CodingKey` type. /// - rejectUnknownKeys: Whether generated decoding rejects /// unsupported keys. - /// - deferRootUnknownKeyValidation: Whether root validation is + /// - deferUnknownKeyValidation: Whether strict validation is /// emitted after superclass decoding. /// /// - Returns: The new location updated with provided context. static func withCoder( _ coder: TokenSyntax, keyType: ExprSyntax, rejectUnknownKeys: Bool = false, - deferRootUnknownKeyValidation: Bool = false + deferUnknownKeyValidation: Bool = false, + additionalAllowedKeys: [String] = [] ) -> Self { .init( context: .coder(coder, keyType: keyType), progress: .init(), rejectUnknownKeys: rejectUnknownKeys, - deferRootUnknownKeyValidation: deferRootUnknownKeyValidation + deferUnknownKeyValidation: deferUnknownKeyValidation, + additionalAllowedKeys: additionalAllowedKeys ) } @@ -94,7 +99,8 @@ extension PropertyVariableTreeNode { .init( context: .container(container, key: key), progress: progress, rejectUnknownKeys: rejectUnknownKeys, - deferRootUnknownKeyValidation: false + deferUnknownKeyValidation: deferUnknownKeyValidation, + additionalAllowedKeys: [] ) } diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index cf3e0f1a59..7d6bd07744 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -73,7 +73,7 @@ extension PropertyVariableTreeNode { } let strictValidation = location.rejectUnknownKeys - && !location.deferRootUnknownKeyValidation + && !location.deferUnknownKeyValidation let directVariables = data?.variables ?? variables let hasDirectDecodableVariables = directVariables.contains { $0.decode ?? true @@ -81,6 +81,7 @@ extension PropertyVariableTreeNode { let allowedKeys: [String]? = if strictValidation { Array(data?.allowedKeys ?? self.allowedKeys) + + location.additionalAllowedKeys } else { nil } @@ -121,7 +122,11 @@ extension PropertyVariableTreeNode { .reduce(.ifMissing([], ifError: []), +) .represented( - context: location.context, nestedContainer: decodingContainer, + context: location.context, + nestedContainer: + location.rejectUnknownKeys + && !location.deferUnknownKeyValidation + ? nil : decodingContainer, nestedContainerHasVariables: !self.children.lazy .flatMap(\.value.variables) .filter { $0.decode ?? true }.isEmpty, diff --git a/Sources/PluginCore/Variables/StrictDecodingSyntax.swift b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift index d877292f35..2b871c76f3 100644 --- a/Sources/PluginCore/Variables/StrictDecodingSyntax.swift +++ b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift @@ -20,18 +20,19 @@ package func strictDecodingValidationSyntax( } } -/// Generates registration for class keys used by inherited strict decoding. -package func strictDecodingRegistrationSyntax( - keys: [String] -) -> CodeBlockItemListSyntax { - let values = keys.map { - let value = - $0 - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - return "\"\(value)\"" +/// Generates a dictionary literal for strict decoding key metadata. +package func strictDecodingMetadataSyntax( + _ metadata: [String: [String]] +) -> ExprSyntax { + let entries = metadata.keys.sorted().map { path in + let keys = metadata[path, default: []].map { + let value = + $0 + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(value)\"" + }.joined(separator: ", ") + return "\"\(path)\": [\(keys)]" }.joined(separator: ", ") - return CodeBlockItemListSyntax { - "MetaCodableStrictDecodingRegistry.register(type: Self.self, keys: [\(raw: values)])" - } + return ExprSyntax(stringLiteral: "[\(entries)]") } diff --git a/Sources/PluginCore/Variables/Type/ClassVariable.swift b/Sources/PluginCore/Variables/Type/ClassVariable.swift index 9e603bedf2..022916dae8 100644 --- a/Sources/PluginCore/Variables/Type/ClassVariable.swift +++ b/Sources/PluginCore/Variables/Type/ClassVariable.swift @@ -127,12 +127,23 @@ struct ClassVariable: TypeVariable, DeclaredVariable { var modifiers: DeclModifierListSyntax = [.init(name: "required")] let method = location.method let conformance = TypeSyntax(stringLiteral: method.protocol) + let strictDecoder = context.makeUniqueName("strictDecoder") if location.conformance == nil, implementDecodable(location: location) { + let overriddenMethod = + if location.rejectUnknownKeys && (inherits?.decodable ?? true) { + TypeCodingLocation.Method( + name: method.name, protocol: method.protocol, + argLabel: method.argLabel, arg: strictDecoder, + argType: method.argType + ) + } else { + method + } newLocation = .init( - method: method, conformance: conformance, + method: overriddenMethod, conformance: conformance, rejectUnknownKeys: location.rejectUnknownKeys, - deferRootUnknownKeyValidation: + deferUnknownKeyValidation: location.rejectUnknownKeys && (inherits?.decodable ?? true) ) @@ -145,22 +156,16 @@ struct ClassVariable: TypeVariable, DeclaredVariable { guard let generated = group.decoding(in: context, from: newLocation) else { return nil } - let registration = - location.rejectUnknownKeys - ? strictDecodingRegistrationSyntax( - keys: Array(group.decodingNode.allowedKeys) - ) - : CodeBlockItemListSyntax {} var code: CodeBlockItemListSyntax if overridden, location.rejectUnknownKeys { + let metadata = strictDecodingMetadataSyntax( + group.decodingNode.allowedKeysByPath() + ) code = CodeBlockItemListSyntax { - registration + "let \(strictDecoder) = MetaCodableStrictDecoder(decoder: \(method.arg), allowedKeysByPath: \(metadata))" generated.code - "try super.\(method.name)(\(method.argLabel): \(method.arg))" - strictDecodingValidationSyntax( - decoder: "\(method.arg)" as ExprSyntax, - allowedKeys: Array(group.decodingNode.allowedKeys) - ) + "try super.\(method.name)(\(method.argLabel): \(strictDecoder))" + "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" } } else { code = @@ -170,9 +175,6 @@ struct ClassVariable: TypeVariable, DeclaredVariable { "" } code.insert(contentsOf: generated.code, at: code.startIndex) - if location.rejectUnknownKeys { - code.insert(contentsOf: registration, at: code.startIndex) - } } modifiers.append(contentsOf: generated.modifiers) return .init( diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 07a2381f84..19e75e9739 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -271,21 +271,30 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { let selfType: ExprSyntax = "\(name).self" let code: CodeBlockItemListSyntax if cases.contains(where: { $0.variable.decode ?? true }) { + var rootDecodingKeys = + switcher.rootDecodingKeys ?? codingKeys.allKeys + if switcher.caseDecodingSharesRootContainer { + for key in cases.flatMap({ + $0.variable.decodingAllowedKeys + }) where !rootDecodingKeys.contains(key) { + rootDecodingKeys.append(key) + } + } let switcherLoc = EnumSwitcherLocation( coder: location.method.arg, container: Self.container, keyType: codingKeys.type, selfType: selfType, selfValue: "_", cases: cases, codeExpr: caseDecodeExpr, hasDefaultCase: forceDefault, - rejectUnknownKeys: location.rejectUnknownKeys + rejectUnknownKeys: location.rejectUnknownKeys, + rootDecodingKeys: rootDecodingKeys, + caseDecodingSharesRootContainer: + switcher.caseDecodingSharesRootContainer ) code = CodeBlockItemListSyntax { if location.rejectUnknownKeys, switcher.usesKeyedContainer { - let keys = - switcher.rootDecodingKeys - ?? codingKeys.allKeys strictDecodingValidationSyntax( decoder: "\(location.method.arg)" as ExprSyntax, - allowedKeys: keys + allowedKeys: rootDecodingKeys ) } switcher.decoding(in: context, from: switcherLoc) @@ -340,7 +349,9 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { keyType: codingKeys.type, selfType: selfType, selfValue: expr, cases: cases, codeExpr: caseEncodeExpr, hasDefaultCase: forceDefault, - rejectUnknownKeys: false + rejectUnknownKeys: false, + rootDecodingKeys: [], + caseDecodingSharesRootContainer: false ) code = CodeBlockItemListSyntax { switcher.encoding(in: context, to: switcherLocation) diff --git a/Sources/PluginCore/Variables/Type/MemberGroup.swift b/Sources/PluginCore/Variables/Type/MemberGroup.swift index e14cf7fdc8..6ffd867d4f 100644 --- a/Sources/PluginCore/Variables/Type/MemberGroup.swift +++ b/Sources/PluginCore/Variables/Type/MemberGroup.swift @@ -97,8 +97,8 @@ where let nLocation = PropertyVariableTreeNode.CodingLocation.withCoder( location.method.arg, keyType: codingKeys.type, rejectUnknownKeys: location.rejectUnknownKeys, - deferRootUnknownKeyValidation: - location.deferRootUnknownKeyValidation + deferUnknownKeyValidation: + location.deferUnknownKeyValidation ) decodingNode.decoding(in: context, from: nLocation).combined() nLocation.decoding(in: context) diff --git a/Sources/PluginCore/Variables/Type/TypeVariable.swift b/Sources/PluginCore/Variables/Type/TypeVariable.swift index d26a631013..43a48b6b8c 100644 --- a/Sources/PluginCore/Variables/Type/TypeVariable.swift +++ b/Sources/PluginCore/Variables/Type/TypeVariable.swift @@ -152,8 +152,8 @@ package struct TypeCodingLocation { let conformance: TypeSyntax? /// Whether generated decoding rejects unsupported keys. let rejectUnknownKeys: Bool - /// Whether root validation is emitted after superclass decoding. - let deferRootUnknownKeyValidation: Bool + /// Whether strict validation is emitted after superclass decoding. + let deferUnknownKeyValidation: Bool /// Creates type decoding/encoding data from provided data. /// @@ -164,7 +164,7 @@ package struct TypeCodingLocation { self.init( method: method, conformance: conformance, rejectUnknownKeys: false, - deferRootUnknownKeyValidation: false + deferUnknownKeyValidation: false ) } @@ -175,17 +175,17 @@ package struct TypeCodingLocation { /// - conformance: The conformed type. /// - rejectUnknownKeys: Whether generated decoding rejects unsupported /// keys. - /// - deferRootUnknownKeyValidation: Whether root validation is emitted + /// - deferUnknownKeyValidation: Whether strict validation is emitted /// after superclass decoding. package init( method: Method, conformance: TypeSyntax?, rejectUnknownKeys: Bool, - deferRootUnknownKeyValidation: Bool = false + deferUnknownKeyValidation: Bool = false ) { self.method = method self.conformance = conformance self.rejectUnknownKeys = rejectUnknownKeys - self.deferRootUnknownKeyValidation = deferRootUnknownKeyValidation + self.deferUnknownKeyValidation = deferUnknownKeyValidation } } diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 8a7b4c99e7..303bdc4f24 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -112,6 +112,44 @@ struct StrictDecodingTests { } } + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictNestedBase { + @CodedIn("payload") + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictNestedChild: StrictNestedBase { + @CodedIn("payload") + var child: String = "" + + override init() { + super.init() + } + } + + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveBase { + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictPermissiveChild: PermissiveBase { + var child: String = "" + + override init() { + super.init() + } + } + @Codable(rejectUnknownKeys: true) actor StrictActor { let value: String @@ -138,6 +176,24 @@ struct StrictDecodingTests { case store(key: String, value: Int) } + @Codable(rejectUnknownKeys: true) + @UnTagged + enum StrictUntaggedCommand { + case first(a: Int) + case second(b: String) + } + + @Codable(rejectUnknownKeys: true) + enum StrictRawCommand: String { + case ready + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("meta", "kind") + enum StrictNestedRawCommand: String { + case ready + } + @Test func strictExpansionAddsValidation() throws { assertMacroExpansion( @@ -265,6 +321,12 @@ struct StrictDecodingTests { """, containing: ["extra"] ) + + try expectDataCorrupted( + StrictNestedRecord.self, + json: #"{"payload":{"value":"decoded","payload":true}}"#, + containing: ["payload"] + ) } @Test @@ -399,6 +461,28 @@ struct StrictDecodingTests { ) } + @Test + func strictNestedClassInheritanceMergesContainerPaths() throws { + let data = Data( + #"{"payload":{"base":"base","child":"child"}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictNestedChild.self, from: data + ) + #expect(decoded.base == "base") + #expect(decoded.child == "child") + } + + @Test + func strictChildOfPermissiveClassAcceptsSuperclassKeys() throws { + let data = Data(#"{"base":"base","child":"child"}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictPermissiveChild.self, from: data + ) + #expect(decoded.base == "base") + #expect(decoded.child == "child") + } + @Test func strictExternalEnumsRejectUnknownKeys() throws { let data = try encodedData( @@ -443,6 +527,52 @@ struct StrictDecodingTests { data: data, containing: ["extra"] ) + + var rootObject = try #require( + JSONSerialization.jsonObject(with: encoded) as? [String: Any] + ) + rootObject["value"] = 123 + let rootData = try JSONSerialization.data( + withJSONObject: rootObject + ) + try expectDataCorrupted( + StrictAdjacentCommand.self, + data: rootData, + containing: ["value"] + ) + } + + @Test + func strictUntaggedEnumsValidateEachAttemptedCase() throws { + try expectDataCorrupted( + StrictUntaggedCommand.self, + json: #"{"b":"ok","extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictRawValueEnumsRemainScalar() throws { + let data = Data(#""ready""#.utf8) + let decoded = try JSONDecoder().decode( + StrictRawCommand.self, from: data + ) + #expect(decoded == .ready) + } + + @Test + func strictNestedRawValueEnumsValidateTagContainers() throws { + let validData = Data(#"{"meta":{"kind":"ready"}}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictNestedRawCommand.self, from: validData + ) + #expect(decoded == .ready) + + try expectDataCorrupted( + StrictNestedRawCommand.self, + json: #"{"meta":{"kind":"ready","extra":true}}"#, + containing: ["extra"] + ) } @Test From e94dd5e99efbd5dbe568aab9a3739caa3ec8d576 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 09:54:54 -0500 Subject: [PATCH 09/50] Refine strict decoding metadata propagation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Property/Data/DecodingFallback.swift | 1 - .../Tree/PropertyVariableTreeNode.swift | 1 - .../Variables/StrictDecodingSyntax.swift | 34 ++++++++++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift b/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift index 8ef15cfbe4..2b95400a37 100644 --- a/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift +++ b/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift @@ -1,4 +1,3 @@ -import Foundation import SwiftSyntax /// Represents possible fallback options for decoding failure. diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index 7d6bd07744..dbe1bcb7a8 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -1,4 +1,3 @@ -import Foundation import OrderedCollections import SwiftSyntax import SwiftSyntaxBuilder diff --git a/Sources/PluginCore/Variables/StrictDecodingSyntax.swift b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift index 2b871c76f3..4145615bf3 100644 --- a/Sources/PluginCore/Variables/StrictDecodingSyntax.swift +++ b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift @@ -1,7 +1,25 @@ -import Foundation import SwiftSyntax import SwiftSyntaxBuilder +private func escapedStringLiteral(_ value: String) -> String { + value.reduce(into: "") { result, character in + switch character { + case "\\": + result += "\\\\" + case "\"": + result += "\\\"" + case "\n": + result += "\\n" + case "\r": + result += "\\r" + case "\t": + result += "\\t" + default: + result.append(character) + } + } +} + /// Generates validation for unsupported keys at a keyed decoding container. package func strictDecodingValidationSyntax( decoder: ExprSyntax, @@ -9,11 +27,7 @@ package func strictDecodingValidationSyntax( ) -> CodeBlockItemListSyntax { guard let allowedKeys else { return [] } let keys = allowedKeys.map { - let value = - $0 - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - return "\"\(value)\"" + "\"\(escapedStringLiteral($0))\"" }.joined(separator: ", ") return CodeBlockItemListSyntax { "try \(decoder).rejectUnknownKeys(allowedKeys: [\(raw: keys)], type: Self.self)" @@ -26,13 +40,9 @@ package func strictDecodingMetadataSyntax( ) -> ExprSyntax { let entries = metadata.keys.sorted().map { path in let keys = metadata[path, default: []].map { - let value = - $0 - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - return "\"\(value)\"" + "\"\(escapedStringLiteral($0))\"" }.joined(separator: ", ") - return "\"\(path)\": [\(keys)]" + return "\"\(escapedStringLiteral(path))\": [\(keys)]" }.joined(separator: ", ") return ExprSyntax(stringLiteral: "[\(entries)]") } From af2cca3ee59890a8185494b7777ed8301907071f Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 10:11:28 -0500 Subject: [PATCH 10/50] Harden strict decoding validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 30 ++++++++++++++----- .../Switcher/AdjacentlyTaggableSwitcher.swift | 5 +++- .../AdjacentlyTaggedEnumSwitcher.swift | 4 +-- .../Enum/Switcher/AnyEnumSwitcher.swift | 2 ++ .../Enum/Switcher/EnumSwitcherVariable.swift | 4 +++ .../Enum/Switcher/UnTaggedEnumSwitcher.swift | 10 ++++--- .../PropertyVariableTreeNode+CodingData.swift | 7 +++-- .../Variables/StrictDecodingSyntax.swift | 4 +++ .../Variables/Type/EnumVariable.swift | 12 +++++++- .../StrictDecodingTests.swift | 24 +++++++++++++++ 10 files changed, 83 insertions(+), 19 deletions(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index b85a73eac8..ef5ac74636 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -15,8 +15,14 @@ public struct MetaCodableStrictDecoder: Decoder { decoder: any Decoder, allowedKeysByPath: [String: [String]] ) { - self.base = decoder - self.state = .init(allowedKeysByPath: allowedKeysByPath) + if let decoder = decoder as? MetaCodableStrictDecoder { + self.base = decoder.base + self.state = decoder.state + self.state.merge(allowedKeysByPath: allowedKeysByPath) + } else { + self.base = decoder + self.state = .init(allowedKeysByPath: allowedKeysByPath) + } } fileprivate init(base: any Decoder, state: State) { @@ -56,7 +62,7 @@ public struct MetaCodableStrictDecoder: Decoder { } fileprivate final class State { - let allowedKeysByPath: [String: Set] + var allowedKeysByPath: [String: Set] var observedKeysByPath: [String: Set] = [:] init(allowedKeysByPath: [String: [String]]) { @@ -75,8 +81,17 @@ public struct MetaCodableStrictDecoder: Decoder { ) } - static func path(for codingPath: [any CodingKey]) -> String { - codingPath.map(\.stringValue).joined(separator: ".") + func merge(allowedKeysByPath: [String: [String]]) { + for (path, keys) in allowedKeysByPath { + self.allowedKeysByPath[path, default: []].formUnion(keys) + } + } + + fileprivate static func path(for codingPath: [any CodingKey]) -> String + { + codingPath.map { key in + "\(key.stringValue.utf8.count):\(key.stringValue)" + }.joined(separator: "|") } } } @@ -309,8 +324,9 @@ where Key == DynamicCodableIdentifier { } for key in allKeys { - let childPath = - path.isEmpty ? key.stringValue : "\(path).\(key.stringValue)" + let childPath = MetaCodableStrictDecoder.State.path( + for: codingPath + [key] + ) guard allowedKeysByPath[childPath] != nil, let nested = try? nestedContainer( keyedBy: DynamicCodableIdentifier.self, diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift index 519f2c13fa..4c70b80c67 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift @@ -242,7 +242,10 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { in: context, from: .withCoder( coder, keyType: location.keyType, - rejectUnknownKeys: location.rejectUnknownKeys + rejectUnknownKeys: location.rejectUnknownKeys, + additionalAllowedKeys: + location.caseDecodingSharesRootContainer + ? location.rootDecodingKeys : [] ) ).combined() diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift index b38d431b75..4836df78d0 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift @@ -25,9 +25,7 @@ where Wrapped: AdjacentlyTaggableSwitcher { /// The exact root keys used by the wrapped switcher when available. var rootDecodingKeys: [String]? { base.rootDecodingKeys } /// Adjacent case values are decoded from the content container. - var caseDecodingSharesRootContainer: Bool { - base.caseDecodingSharesRootContainer - } + var caseDecodingSharesRootContainer: Bool { false } /// Creates switcher variable with provided data. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift index b042f71000..0fa10ff98d 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift @@ -21,6 +21,8 @@ struct AnyEnumSwitcher: EnumSwitcherVariable { var caseDecodingSharesRootContainer: Bool { base.caseDecodingSharesRootContainer } + /// Whether the wrapped switcher allows direct decoding cases. + var allowsDirectDecoding: Bool { base.allowsDirectDecoding } /// Provides node at which case associated variables are registered. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift index f387f4799e..557f09976b 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift @@ -15,6 +15,8 @@ where var rootDecodingKeys: [String]? { get } /// Whether case values are decoded in the same root container. var caseDecodingSharesRootContainer: Bool { get } + /// Whether some cases may decode directly from scalar or unkeyed values. + var allowsDirectDecoding: Bool { get } /// Provides node at which case associated variables are registered. /// /// The returned node can be used by enum-case variables to register @@ -76,6 +78,8 @@ extension EnumSwitcherVariable { var rootDecodingKeys: [String]? { nil } /// Case values are nested unless a switcher opts into sharing the root. var caseDecodingSharesRootContainer: Bool { false } + /// Switchers do not mix direct and keyed representations by default. + var allowsDirectDecoding: Bool { false } /// Update provided variable data. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index d7ddb87d85..6250052f39 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -5,12 +5,14 @@ import SwiftSyntaxMacros /// /// Tries decoding associated variables for each case until success. struct UnTaggedEnumSwitcher: EnumSwitcherVariable { - /// Untagged decoding uses a keyed container when cases have keyed values. - var usesKeyedContainer: Bool { !node.allowedKeys.isEmpty } - /// The keys shared by keyed untagged cases. - var rootDecodingKeys: [String]? { Array(node.allowedKeys) } + /// Untagged cases may use scalar, unkeyed, or keyed representations. + var usesKeyedContainer: Bool { false } + /// Untagged cases do not have a single root schema. + var rootDecodingKeys: [String]? { nil } /// Untagged case values share their direct decoder. var caseDecodingSharesRootContainer: Bool { true } + /// Untagged enums may mix direct and keyed cases. + var allowsDirectDecoding: Bool { true } /// The node at which variables are registered. /// /// Associated variables for all cases are registered with the path diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift index a4d2177949..ed5b7c778d 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingData.swift @@ -53,9 +53,10 @@ extension VariableTreeNode { func allowedKeysByPath( prefix: [String] = [] ) -> [String: [String]] { - var result = [ - prefix.joined(separator: "."): Array(allowedKeys) - ] + var result: [String: [String]] = [:] + if prefix.isEmpty || !children.isEmpty { + result[strictDecodingPathKey(prefix)] = Array(allowedKeys) + } for (key, child) in children { result.merge( child.allowedKeysByPath( diff --git a/Sources/PluginCore/Variables/StrictDecodingSyntax.swift b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift index 4145615bf3..bdfe657ace 100644 --- a/Sources/PluginCore/Variables/StrictDecodingSyntax.swift +++ b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift @@ -20,6 +20,10 @@ private func escapedStringLiteral(_ value: String) -> String { } } +package func strictDecodingPathKey(_ components: [String]) -> String { + components.map { "\($0.utf8.count):\($0)" }.joined(separator: "|") +} + /// Generates validation for unsupported keys at a keyed decoding container. package func strictDecodingValidationSyntax( decoder: ExprSyntax, diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 19e75e9739..f67dde5a58 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -271,6 +271,14 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { let selfType: ExprSyntax = "\(name).self" let code: CodeBlockItemListSyntax if cases.contains(where: { $0.variable.decode ?? true }) { + let hasDirectDecodingCase = + switcher.allowsDirectDecoding + && cases.contains { + $0.variable.decodingAllowedKeys.isEmpty + } + let shouldPreflightKeyedContainer = + switcher.usesKeyedContainer + || switcher.allowsDirectDecoding && !hasDirectDecodingCase var rootDecodingKeys = switcher.rootDecodingKeys ?? codingKeys.allKeys if switcher.caseDecodingSharesRootContainer { @@ -291,7 +299,9 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { switcher.caseDecodingSharesRootContainer ) code = CodeBlockItemListSyntax { - if location.rejectUnknownKeys, switcher.usesKeyedContainer { + if location.rejectUnknownKeys + && shouldPreflightKeyedContainer + { strictDecodingValidationSyntax( decoder: "\(location.method.arg)" as ExprSyntax, allowedKeys: rootDecodingKeys diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 303bdc4f24..034a33db06 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -132,6 +132,17 @@ struct StrictDecodingTests { } } + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictNestedGrandChild: StrictNestedChild { + @CodedIn("payload") + var leaf: String = "" + + override init() { + super.init() + } + } + @Codable @Inherits(decodable: false, encodable: false) class PermissiveBase { @@ -473,6 +484,19 @@ struct StrictDecodingTests { #expect(decoded.child == "child") } + @Test + func strictThreeLevelClassInheritancePreservesDecoderMetadata() throws { + let data = Data( + #"{"payload":{"base":"base","child":"child","leaf":"leaf"}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictNestedGrandChild.self, from: data + ) + #expect(decoded.base == "base") + #expect(decoded.child == "child") + #expect(decoded.leaf == "leaf") + } + @Test func strictChildOfPermissiveClassAcceptsSuperclassKeys() throws { let data = Data(#"{"base":"base","child":"child"}"#.utf8) From 2a19a358ce0301d4a939a17af3ce6a2f817185f4 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 10:18:31 -0500 Subject: [PATCH 11/50] Cover mixed untagged enum decoding Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PluginCore/Variables/ComposedVariable.swift | 4 ++++ .../Enum/Case/BasicEnumCaseVariable.swift | 4 ++++ .../Variables/Enum/Case/EnumCaseVariable.swift | 4 ++++ .../PluginCore/Variables/Type/EnumVariable.swift | 2 +- Tests/MetaCodableTests/StrictDecodingTests.swift | 16 ++++++++++++++++ 5 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Sources/PluginCore/Variables/ComposedVariable.swift b/Sources/PluginCore/Variables/ComposedVariable.swift index cb0f860e7e..b3a4139bf7 100644 --- a/Sources/PluginCore/Variables/ComposedVariable.swift +++ b/Sources/PluginCore/Variables/ComposedVariable.swift @@ -189,6 +189,10 @@ where Self: EnumCaseVariable, Wrapped: EnumCaseVariable { var decodingAllowedKeys: [String] { base.decodingAllowedKeys } + /// Provides keyed-decoding requirements from the underlying enum case. + var usesKeyedDecoding: Bool { + base.usesKeyedDecoding + } } extension ComposedVariable where Self: TypeVariable, Wrapped: TypeVariable { diff --git a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift index 35cfaf6fed..07bbe6f70d 100644 --- a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift @@ -69,6 +69,10 @@ struct BasicEnumCaseVariable: EnumCaseVariable { var decodingAllowedKeys: [String] { Array(decodingNode.allowedKeys) } + /// Whether associated values require a keyed decoding container. + var usesKeyedDecoding: Bool { + variables.contains { $0.label != nil } + } /// Creates a new enum-case variable from provided data. /// diff --git a/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift index f04ecb16cd..e38354ae0b 100644 --- a/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift @@ -14,11 +14,15 @@ where CodingLocation == EnumCaseCodingLocation, Generated == EnumCaseGenerated { var variables: [any AssociatedVariable] { get } /// Keys recognized by decoding associated values at the current level. var decodingAllowedKeys: [String] { get } + /// Whether associated values require a keyed decoding container. + var usesKeyedDecoding: Bool { get } } extension EnumCaseVariable { /// No associated decoding keys are exposed by default. var decodingAllowedKeys: [String] { [] } + /// Associated values decode directly by default. + var usesKeyedDecoding: Bool { false } /// The arguments passed to encoding condition. /// diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index f67dde5a58..2ccb5e9de8 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -274,7 +274,7 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { let hasDirectDecodingCase = switcher.allowsDirectDecoding && cases.contains { - $0.variable.decodingAllowedKeys.isEmpty + !$0.variable.usesKeyedDecoding } let shouldPreflightKeyedContainer = switcher.usesKeyedContainer diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 034a33db06..975946fbdb 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -194,6 +194,13 @@ struct StrictDecodingTests { case second(b: String) } + @Codable(rejectUnknownKeys: true) + @UnTagged + enum StrictMixedUntaggedCommand: Equatable { + case text(String) + case record(value: Int) + } + @Codable(rejectUnknownKeys: true) enum StrictRawCommand: String { case ready @@ -575,6 +582,15 @@ struct StrictDecodingTests { ) } + @Test + func strictMixedUntaggedEnumsAllowScalarCases() throws { + let data = Data(#""hello""#.utf8) + let decoded = try JSONDecoder().decode( + StrictMixedUntaggedCommand.self, from: data + ) + #expect(decoded == .text("hello")) + } + @Test func strictRawValueEnumsRemainScalar() throws { let data = Data(#""ready""#.utf8) From 5944181fdd348c243fc1396deb3c79c6f7c9337b Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 10:36:30 -0500 Subject: [PATCH 12/50] Fix inherited path and mixed enum validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 47 +++++++++++----- .../Variables/StrictDecodingSyntax.swift | 17 ++++++ .../Variables/Type/EnumVariable.swift | 24 ++++++--- .../StrictDecodingTests.swift | 53 +++++++++++++++++++ 4 files changed, 122 insertions(+), 19 deletions(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index ef5ac74636..961fb1ae0e 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -21,7 +21,10 @@ public struct MetaCodableStrictDecoder: Decoder { self.state.merge(allowedKeysByPath: allowedKeysByPath) } else { self.base = decoder - self.state = .init(allowedKeysByPath: allowedKeysByPath) + self.state = .init( + allowedKeysByPath: allowedKeysByPath, + rootCodingPath: decoder.codingPath.map(\.stringValue) + ) } } @@ -64,18 +67,32 @@ public struct MetaCodableStrictDecoder: Decoder { fileprivate final class State { var allowedKeysByPath: [String: Set] var observedKeysByPath: [String: Set] = [:] + var observedContainerPaths: Set = [] + let rootCodingPath: [String] - init(allowedKeysByPath: [String: [String]]) { + init( + allowedKeysByPath: [String: [String]], + rootCodingPath: [String] + ) { self.allowedKeysByPath = allowedKeysByPath.mapValues(Set.init) + self.rootCodingPath = rootCodingPath } func record(key: any CodingKey, at path: [any CodingKey]) { - let path = Self.path(for: path) + let path = self.path(for: path) observedKeysByPath[path, default: []].insert(key.stringValue) } + func recordContainer(at path: [any CodingKey]) { + observedContainerPaths.insert(self.path(for: path)) + } + + func hasContainerPath(_ path: String) -> Bool { + observedContainerPaths.contains(path) + } + func additionalKeys(at path: [any CodingKey]) -> Set { - let path = Self.path(for: path) + let path = self.path(for: path) return (allowedKeysByPath[path] ?? []).union( observedKeysByPath[path] ?? [] ) @@ -87,10 +104,14 @@ public struct MetaCodableStrictDecoder: Decoder { } } - fileprivate static func path(for codingPath: [any CodingKey]) -> String - { - codingPath.map { key in - "\(key.stringValue.utf8.count):\(key.stringValue)" + fileprivate func path(for codingPath: [any CodingKey]) -> String { + let components = codingPath.map(\.stringValue) + let relative = + components.starts(with: rootCodingPath) + ? Array(components.dropFirst(rootCodingPath.count)) + : components + return relative.map { component in + "\(component.utf8.count):\(component)" }.joined(separator: "|") } } @@ -216,6 +237,7 @@ private struct StrictKeyedDecodingContainer: forKey key: Key ) throws -> KeyedDecodingContainer { state.record(key: key, at: codingPath) + state.recordContainer(at: codingPath + [key]) let nested = try base.nestedContainer(keyedBy: type, forKey: key) return KeyedDecodingContainer( StrictKeyedDecodingContainer(base: nested, state: state) @@ -324,10 +346,11 @@ where Key == DynamicCodableIdentifier { } for key in allKeys { - let childPath = MetaCodableStrictDecoder.State.path( - for: codingPath + [key] - ) - guard allowedKeysByPath[childPath] != nil, + let childPath = + state?.path(for: codingPath + [key]) ?? path + guard + allowedKeysByPath[childPath] != nil + || state?.hasContainerPath(childPath) == true, let nested = try? nestedContainer( keyedBy: DynamicCodableIdentifier.self, forKey: key diff --git a/Sources/PluginCore/Variables/StrictDecodingSyntax.swift b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift index bdfe657ace..9641b29ce2 100644 --- a/Sources/PluginCore/Variables/StrictDecodingSyntax.swift +++ b/Sources/PluginCore/Variables/StrictDecodingSyntax.swift @@ -38,6 +38,23 @@ package func strictDecodingValidationSyntax( } } +/// Generates conditional validation for mixed scalar/keyed enum decoding. +package func strictDecodingConditionalValidationSyntax( + decoder: ExprSyntax, + keyType: ExprSyntax, + allowedKeys: [String] +) -> CodeBlockItemListSyntax { + let validation = strictDecodingValidationSyntax( + decoder: decoder, allowedKeys: allowedKeys + ) + return CodeBlockItemListSyntax { + try! IfExprSyntax( + "if (try? \(decoder).container(keyedBy: \(keyType))) != nil" + ) { + validation + } + } +} /// Generates a dictionary literal for strict decoding key metadata. package func strictDecodingMetadataSyntax( _ metadata: [String: [String]] diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 2ccb5e9de8..9b95b86483 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -279,6 +279,8 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { let shouldPreflightKeyedContainer = switcher.usesKeyedContainer || switcher.allowsDirectDecoding && !hasDirectDecodingCase + let shouldConditionallyPreflightKeyedContainer = + switcher.allowsDirectDecoding && hasDirectDecodingCase var rootDecodingKeys = switcher.rootDecodingKeys ?? codingKeys.allKeys if switcher.caseDecodingSharesRootContainer { @@ -299,13 +301,21 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { switcher.caseDecodingSharesRootContainer ) code = CodeBlockItemListSyntax { - if location.rejectUnknownKeys - && shouldPreflightKeyedContainer - { - strictDecodingValidationSyntax( - decoder: "\(location.method.arg)" as ExprSyntax, - allowedKeys: rootDecodingKeys - ) + if location.rejectUnknownKeys { + if shouldPreflightKeyedContainer { + strictDecodingValidationSyntax( + decoder: "\(location.method.arg)" as ExprSyntax, + allowedKeys: rootDecodingKeys + ) + } else if shouldConditionallyPreflightKeyedContainer, + !rootDecodingKeys.isEmpty + { + strictDecodingConditionalValidationSyntax( + decoder: "\(location.method.arg)" as ExprSyntax, + keyType: codingKeys.type, + allowedKeys: rootDecodingKeys + ) + } } switcher.decoding(in: context, from: switcherLoc) } diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 975946fbdb..179873ea40 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -84,6 +84,11 @@ struct StrictDecodingTests { let values: [StrictDecodingTests.StrictRecord] } + @Codable + struct StrictEnvelope { + let item: StrictDecodingTests.StrictNestedChild + } + @Codable(rejectUnknownKeys: true) struct StrictEmptyRecord {} @@ -161,6 +166,25 @@ struct StrictDecodingTests { } } + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveNestedBase { + @CodedIn("payload") + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictPermissiveNestedChild: PermissiveNestedBase { + var child: String = "" + + override init() { + super.init() + } + } + @Codable(rejectUnknownKeys: true) actor StrictActor { let value: String @@ -491,6 +515,25 @@ struct StrictDecodingTests { #expect(decoded.child == "child") } + @Test + func strictNestedClassValidationNormalizesEnclosingCodingPaths() throws { + try expectDataCorrupted( + StrictEnvelope.self, + json: """ + { + "item": { + "payload": { + "base": "base", + "child": "child", + "extra": true + } + } + } + """, + containing: ["extra"] + ) + } + @Test func strictThreeLevelClassInheritancePreservesDecoderMetadata() throws { let data = Data( @@ -514,6 +557,16 @@ struct StrictDecodingTests { #expect(decoded.child == "child") } + @Test + func strictChildOfPermissiveClassValidatesInheritedNestedContainers() throws + { + try expectDataCorrupted( + StrictPermissiveNestedChild.self, + json: #"{"child":"c","payload":{"base":"b","extra":true}}"#, + containing: ["extra"] + ) + } + @Test func strictExternalEnumsRejectUnknownKeys() throws { let data = try encodedData( From abc4208fed4190a8c830564166cead2a8224ee16 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 10:37:47 -0500 Subject: [PATCH 13/50] Cover mixed untagged decoding errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tests/MetaCodableTests/StrictDecodingTests.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 179873ea40..1fa211dc6e 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -644,6 +644,15 @@ struct StrictDecodingTests { #expect(decoded == .text("hello")) } + @Test + func strictMixedUntaggedEnumsPreserveUnknownKeyErrors() throws { + try expectDataCorrupted( + StrictMixedUntaggedCommand.self, + json: #"{"value":1,"extra":true}"#, + containing: ["extra"] + ) + } + @Test func strictRawValueEnumsRemainScalar() throws { let data = Data(#""ready""#.utf8) From 8592f2a87c7f86e09cd2b85ecf9d0c9607984af5 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 10:58:53 -0500 Subject: [PATCH 14/50] Preserve strict untagged decoding errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Enum/Switcher/UnTaggedEnumSwitcher.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index 6250052f39..be952774ec 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -145,8 +145,17 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { let generated = `case`.variable.decoding(in: context, from: cLocation) let nResult = decodingSyntax(for: &cases, from: location, in: context) let catchClauses = CatchClauseListSyntax { - CatchClauseSyntax { - nResult.syntax + if location.rejectUnknownKeys { + CatchClauseSyntax("catch let error as DecodingError") { + try! IfExprSyntax("if case .dataCorrupted = error") { + "throw error" + } + nResult.syntax + } + } else { + CatchClauseSyntax { + nResult.syntax + } } } From dee342e6371dad74d99755588cadd6b43dd756d4 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 11:11:47 -0500 Subject: [PATCH 15/50] Preserve untagged strict validation failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Enum/Switcher/UnTaggedEnumSwitcher.swift | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index be952774ec..9feb01796d 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -102,8 +102,16 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { from location: EnumSwitcherLocation ) -> CodeBlockItemListSyntax { var cases = location.cases - let result = decodingSyntax(for: &cases, from: location, in: context) + let strictError = location.rejectUnknownKeys + ? context.makeUniqueName("strictDecodingError") : nil + let result = decodingSyntax( + for: &cases, from: location, in: context, + strictError: strictError + ) return CodeBlockItemListSyntax { + if let strictError { + "var \(strictError): DecodingError?" + } if result.usesError { """ let context = DecodingError.Context( @@ -111,6 +119,11 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { debugDescription: "Couldn't decode any case." ) """ + if let strictError { + try! IfExprSyntax("if let \(strictError)") { + "throw \(strictError)" + } + } "let \(error) = DecodingError.typeMismatch(Self.self, context)" } result.syntax @@ -131,9 +144,23 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { private func decodingSyntax( for cases: inout [EnumVariable.Case], from location: EnumSwitcherLocation, - in context: some MacroExpansionContext + in context: some MacroExpansionContext, + strictError: TokenSyntax? ) -> (usesError: Bool, syntax: CodeBlockItemListSyntax) { - guard !cases.isEmpty else { return (true, "throw \(error)") } + guard !cases.isEmpty else { + if let strictError { + return ( + true, + CodeBlockItemListSyntax { + try! IfExprSyntax("if let \(strictError)") { + "throw \(strictError)" + } + "throw \(error)" + } + ) + } + return (true, "throw \(error)") + } let `case` = cases.removeFirst() let coder = location.coder @@ -143,15 +170,19 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { additionalAllowedKeys: [] ) let generated = `case`.variable.decoding(in: context, from: cLocation) - let nResult = decodingSyntax(for: &cases, from: location, in: context) + let nResult = decodingSyntax( + for: &cases, from: location, in: context, + strictError: strictError + ) let catchClauses = CatchClauseListSyntax { - if location.rejectUnknownKeys { + if let strictError { CatchClauseSyntax("catch let error as DecodingError") { try! IfExprSyntax("if case .dataCorrupted = error") { - "throw error" + "\(strictError) = error" } nResult.syntax } + CatchClauseSyntax { nResult.syntax } } else { CatchClauseSyntax { nResult.syntax @@ -162,6 +193,9 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { let eCase = `case`.variable let tVisitor = ThrowingSyntaxVisitor(viewMode: .sourceAccurate) let doBlock = CodeBlockItemListSyntax { + if location.rejectUnknownKeys { + generated.code.containerSyntax + } generated.code.codingSyntax generated.code.conditionalSyntax location.codeExpr(eCase.name, eCase.variables) @@ -177,7 +211,9 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { eVisitor.walk(doBlock) let syntax = CodeBlockItemListSyntax { - generated.code.containerSyntax + if !location.rejectUnknownKeys { + generated.code.containerSyntax + } if tVisitor.throws { DoStmtSyntax(catchClauses: catchClauses) { doBlock From 49ea58ccd2913cbd2188b704fbb428807a92481f Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 11:27:34 -0500 Subject: [PATCH 16/50] Avoid exponential untagged enum expansion Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Enum/Switcher/UnTaggedEnumSwitcher.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index 9feb01796d..80628c3949 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -102,7 +102,8 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { from location: EnumSwitcherLocation ) -> CodeBlockItemListSyntax { var cases = location.cases - let strictError = location.rejectUnknownKeys + let strictError = + location.rejectUnknownKeys ? context.makeUniqueName("strictDecodingError") : nil let result = decodingSyntax( for: &cases, from: location, in: context, @@ -176,13 +177,14 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { ) let catchClauses = CatchClauseListSyntax { if let strictError { - CatchClauseSyntax("catch let error as DecodingError") { - try! IfExprSyntax("if case .dataCorrupted = error") { - "\(strictError) = error" + CatchClauseSyntax { + try! IfExprSyntax( + "if let decodingError = error as? DecodingError, case .dataCorrupted = decodingError" + ) { + "\(strictError) = decodingError" } nResult.syntax } - CatchClauseSyntax { nResult.syntax } } else { CatchClauseSyntax { nResult.syntax From e6b72ef55ad4b26f99438c7bbfc92edbc39f2fe5 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 11:42:40 -0500 Subject: [PATCH 17/50] Allow delegated untagged object cases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Variables/Type/EnumVariable.swift | 2 +- .../StrictDecodingTests.swift | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 9b95b86483..90371c00a4 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -280,7 +280,7 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { switcher.usesKeyedContainer || switcher.allowsDirectDecoding && !hasDirectDecodingCase let shouldConditionallyPreflightKeyedContainer = - switcher.allowsDirectDecoding && hasDirectDecodingCase + false var rootDecodingKeys = switcher.rootDecodingKeys ?? codingKeys.allKeys if switcher.caseDecodingSharesRootContainer { diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 1fa211dc6e..de7c349006 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -225,6 +225,13 @@ struct StrictDecodingTests { case record(value: Int) } + @Codable(rejectUnknownKeys: true) + @UnTagged + enum StrictDelegatedUntaggedCommand { + case record(StrictDecodingTests.StrictRecord) + case number(value: Int) + } + @Codable(rejectUnknownKeys: true) enum StrictRawCommand: String { case ready @@ -653,6 +660,20 @@ struct StrictDecodingTests { ) } + @Test + func strictUntaggedEnumsAllowDelegatedStrictObjects() throws { + let data = Data(#"{"name":"n","count":1}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictDelegatedUntaggedCommand.self, from: data + ) + if case .record(let value) = decoded { + #expect(value.name == "n") + #expect(value.count == 1) + } else { + Issue.record("Expected delegated record case") + } + } + @Test func strictRawValueEnumsRemainScalar() throws { let data = Data(#""ready""#.utf8) From fe681a81133b26529e723a97a951ff8e2444acf8 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 12:21:36 -0500 Subject: [PATCH 18/50] Harden current-head strict decoding review fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Expansion/Options/Options.swift | 26 +++++- .../AdjacentlyTaggedEnumSwitcher.swift | 4 + .../Enum/Switcher/AnyEnumSwitcher.swift | 4 + .../Enum/Switcher/EnumSwitcherVariable.swift | 6 ++ .../ExternallyTaggedEnumSwitcher.swift | 2 + .../InternallyTaggedEnumSwitcher.swift | 4 + .../Switcher/TaggedEnumSwitcherVariable.swift | 2 +- .../Enum/Switcher/UnTaggedEnumSwitcher.swift | 2 + .../Tree/PropertyVariableTreeNode.swift | 7 +- .../Variables/Type/ClassVariable.swift | 12 ++- .../Variables/Type/EnumVariable.swift | 13 ++- .../CodableInheritanceTests.swift | 10 ++- .../StrictDecodingTests.swift | 85 +++++++++++++++++++ 13 files changed, 159 insertions(+), 18 deletions(-) diff --git a/Sources/PluginCore/Expansion/Options/Options.swift b/Sources/PluginCore/Expansion/Options/Options.swift index e2b74d8b6c..ac29ba6ca2 100644 --- a/Sources/PluginCore/Expansion/Options/Options.swift +++ b/Sources/PluginCore/Expansion/Options/Options.swift @@ -53,12 +53,30 @@ extension AttributeExpander { .as(LabeledExprListSyntax.self), let argument = arguments.first(where: { $0.label?.text == "rejectUnknownKeys" - }), - let literal = argument.expression - .as(BooleanLiteralExprSyntax.self) + }) else { return nil } - return literal.literal.tokenKind == .keyword(.true) + return Self.booleanValue(argument.expression) }.first ?? false } + + /// Resolves boolean expressions supported by the macro argument. + private static func booleanValue(_ expression: ExprSyntax) -> Bool? { + if let literal = expression.as(BooleanLiteralExprSyntax.self) { + return literal.literal.tokenKind == .keyword(.true) + } + if let expression = expression.as(TupleExprSyntax.self), + expression.elements.count == 1, + expression.elements.first?.label == nil + { + return booleanValue(expression.elements.first!.expression) + } + if let expression = expression.as(PrefixOperatorExprSyntax.self), + expression.operator.text == "!", + let value = booleanValue(expression.expression) + { + return !value + } + return nil + } } } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift index 4836df78d0..9c134c29fb 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift @@ -24,6 +24,10 @@ where Wrapped: AdjacentlyTaggableSwitcher { var usesKeyedContainer: Bool { base.usesKeyedContainer } /// The exact root keys used by the wrapped switcher when available. var rootDecodingKeys: [String]? { base.rootDecodingKeys } + /// Discriminator keys from the wrapped switcher. + var discriminatorDecodingKeys: [String] { + base.discriminatorDecodingKeys + } /// Adjacent case values are decoded from the content container. var caseDecodingSharesRootContainer: Bool { false } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift index 0fa10ff98d..023dc4f5dd 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift @@ -17,6 +17,10 @@ struct AnyEnumSwitcher: EnumSwitcherVariable { var usesKeyedContainer: Bool { base.usesKeyedContainer } /// The exact root keys used by the wrapped switcher when available. var rootDecodingKeys: [String]? { base.rootDecodingKeys } + /// Discriminator keys from the wrapped switcher. + var discriminatorDecodingKeys: [String] { + base.discriminatorDecodingKeys + } /// Whether case values share the wrapped switcher's root container. var caseDecodingSharesRootContainer: Bool { base.caseDecodingSharesRootContainer diff --git a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift index 557f09976b..88d87fee54 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift @@ -13,6 +13,8 @@ where var usesKeyedContainer: Bool { get } /// The exact root keys used by the switcher when available. var rootDecodingKeys: [String]? { get } + /// Keys used by the switcher itself before case values are decoded. + var discriminatorDecodingKeys: [String] { get } /// Whether case values are decoded in the same root container. var caseDecodingSharesRootContainer: Bool { get } /// Whether some cases may decode directly from scalar or unkeyed values. @@ -76,6 +78,8 @@ extension EnumSwitcherVariable { var usesKeyedContainer: Bool { true } /// Switchers use the type coding key map by default. var rootDecodingKeys: [String]? { nil } + /// Switchers have no separate discriminator keys by default. + var discriminatorDecodingKeys: [String] { [] } /// Case values are nested unless a switcher opts into sharing the root. var caseDecodingSharesRootContainer: Bool { false } /// Switchers do not mix direct and keyed representations by default. @@ -142,6 +146,8 @@ package struct EnumSwitcherLocation { let rejectUnknownKeys: Bool /// Keys accepted at the enum root container. let rootDecodingKeys: [String] + /// Keys accepted by the discriminator/case switcher itself. + let discriminatorDecodingKeys: [String] /// Whether associated case values share the enum root container. let caseDecodingSharesRootContainer: Bool } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift index 11616a1c04..de61dffde0 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/ExternallyTaggedEnumSwitcher.swift @@ -21,6 +21,8 @@ package struct ExternallyTaggedEnumSwitcher: TaggedEnumSwitcherVariable { let contentEncoder: TokenSyntax /// The enum tag keys recognized by external tagging. var rootDecodingKeys: [String]? { decodingKeys.allKeys } + /// External tags are the switcher's discriminator keys. + var discriminatorDecodingKeys: [String] { decodingKeys.allKeys } /// Provides node at which case associated variables are registered. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift index 38dd86513a..b1a17205c5 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift @@ -16,6 +16,10 @@ where var usesKeyedContainer: Bool { !keyPath.decoding.isEmpty } /// The discriminator keys used at the enum root. var rootDecodingKeys: [String]? { Array(decodingNode.allowedKeys) } + /// The discriminator keys used by internal tagging. + var discriminatorDecodingKeys: [String] { + Array(decodingNode.allowedKeys) + } /// Internally tagged case values share the discriminator container. var caseDecodingSharesRootContainer: Bool { true } /// The identifier variable build action type. diff --git a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift index 755a3d1862..c9123b3d8b 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift @@ -56,7 +56,7 @@ extension TaggedEnumSwitcherVariable { rejectUnknownKeys: location.rejectUnknownKeys, additionalAllowedKeys: location.caseDecodingSharesRootContainer - ? location.rootDecodingKeys : [] + ? location.discriminatorDecodingKeys : [] ) let generated = `case`.decoding( in: context, from: cLocation diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index 80628c3949..98d624d1dd 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -11,6 +11,8 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { var rootDecodingKeys: [String]? { nil } /// Untagged case values share their direct decoder. var caseDecodingSharesRootContainer: Bool { true } + /// Untagged enums have no discriminator keys. + var discriminatorDecodingKeys: [String] { [] } /// Untagged enums may mix direct and keyed cases. var allowsDirectDecoding: Bool { true } /// The node at which variables are registered. diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index dbe1bcb7a8..a384f2c1fe 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -70,13 +70,14 @@ extension PropertyVariableTreeNode { ?? children.contains { _, node in node.linkedVariables.contains { $0.decode ?? true } } - let strictValidation = - location.rejectUnknownKeys - && !location.deferUnknownKeyValidation let directVariables = data?.variables ?? variables let hasDirectDecodableVariables = directVariables.contains { $0.decode ?? true } + let strictValidation = + location.rejectUnknownKeys + && !location.deferUnknownKeyValidation + && !hasDirectDecodableVariables let allowedKeys: [String]? = if strictValidation { Array(data?.allowedKeys ?? self.allowedKeys) diff --git a/Sources/PluginCore/Variables/Type/ClassVariable.swift b/Sources/PluginCore/Variables/Type/ClassVariable.swift index 022916dae8..dd15f93319 100644 --- a/Sources/PluginCore/Variables/Type/ClassVariable.swift +++ b/Sources/PluginCore/Variables/Type/ClassVariable.swift @@ -131,7 +131,7 @@ struct ClassVariable: TypeVariable, DeclaredVariable { if location.conformance == nil, implementDecodable(location: location) { let overriddenMethod = - if location.rejectUnknownKeys && (inherits?.decodable ?? true) { + if inherits?.decodable ?? true { TypeCodingLocation.Method( name: method.name, protocol: method.protocol, argLabel: method.argLabel, arg: strictDecoder, @@ -156,16 +156,20 @@ struct ClassVariable: TypeVariable, DeclaredVariable { guard let generated = group.decoding(in: context, from: newLocation) else { return nil } + let metadataMap = group.decodingNode.allowedKeysByPath() + let hasKeyedMetadata = metadataMap.values.contains { !$0.isEmpty } var code: CodeBlockItemListSyntax - if overridden, location.rejectUnknownKeys { + if overridden { let metadata = strictDecodingMetadataSyntax( - group.decodingNode.allowedKeysByPath() + metadataMap ) code = CodeBlockItemListSyntax { "let \(strictDecoder) = MetaCodableStrictDecoder(decoder: \(method.arg), allowedKeysByPath: \(metadata))" generated.code "try super.\(method.name)(\(method.argLabel): \(strictDecoder))" - "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" + if location.rejectUnknownKeys, hasKeyedMetadata { + "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" + } } } else { code = diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 90371c00a4..037f130c12 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -276,9 +276,15 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { && cases.contains { !$0.variable.usesKeyedDecoding } + let hasDelegatedDirectCase = + switcher.caseDecodingSharesRootContainer + && cases.contains { + !$0.variable.usesKeyedDecoding + } let shouldPreflightKeyedContainer = - switcher.usesKeyedContainer - || switcher.allowsDirectDecoding && !hasDirectDecodingCase + !hasDelegatedDirectCase + && (switcher.usesKeyedContainer + || switcher.allowsDirectDecoding && !hasDirectDecodingCase) let shouldConditionallyPreflightKeyedContainer = false var rootDecodingKeys = @@ -297,6 +303,8 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { hasDefaultCase: forceDefault, rejectUnknownKeys: location.rejectUnknownKeys, rootDecodingKeys: rootDecodingKeys, + discriminatorDecodingKeys: + switcher.discriminatorDecodingKeys, caseDecodingSharesRootContainer: switcher.caseDecodingSharesRootContainer ) @@ -371,6 +379,7 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { hasDefaultCase: forceDefault, rejectUnknownKeys: false, rootDecodingKeys: [], + discriminatorDecodingKeys: [], caseDecodingSharesRootContainer: false ) code = CodeBlockItemListSyntax { diff --git a/Tests/MetaCodableTests/CodableInheritanceTests.swift b/Tests/MetaCodableTests/CodableInheritanceTests.swift index 80a381fab5..0615a5c239 100644 --- a/Tests/MetaCodableTests/CodableInheritanceTests.swift +++ b/Tests/MetaCodableTests/CodableInheritanceTests.swift @@ -174,9 +174,10 @@ struct CodableInheritanceTests { override init() { super.init() } required init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let __macro_local_13strictDecoderfMu_ = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) + let container = try __macro_local_13strictDecoderfMu_.container(keyedBy: CodingKeys.self) self.value = try container.decode(String.self, forKey: CodingKeys.value) - try super.init(from: decoder) + try super.init(from: __macro_local_13strictDecoderfMu_) } override func encode(to encoder: any Encoder) throws { @@ -249,9 +250,10 @@ struct CodableInheritanceTests { override init() { super.init() } required init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let __macro_local_13strictDecoderfMu_ = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) + let container = try __macro_local_13strictDecoderfMu_.container(keyedBy: CodingKeys.self) self.value = try container.decode(String.self, forKey: CodingKeys.value) - try super.init(from: decoder) + try super.init(from: __macro_local_13strictDecoderfMu_) } func encode(to encoder: any Encoder) throws { diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index de7c349006..4649d0e9eb 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -17,6 +17,18 @@ struct StrictDecodingTests { let name: String } + @Codable + struct PermissivePayload { + let name: String + } + + @Codable(rejectUnknownKeys: true) + struct StrictMixedDirectRecord { + @CodedAt + let payload: StrictDecodingTests.PermissivePayload + let count: Int + } + @ConformDecodable(rejectUnknownKeys: true) struct StrictDecodableRecord { let value: String @@ -117,6 +129,41 @@ struct StrictDecodingTests { } } + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictBaseForPermissiveChild { + var base: String = "" + + init() {} + } + + @Codable + @Inherits(decodable: true, encodable: true) + class PermissiveStrictChild: StrictBaseForPermissiveChild { + var child: String = "" + + override init() { + super.init() + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictScalarBase { + @CodedAt + var value: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictScalarChild: StrictScalarBase { + override init() { + super.init() + } + } + @Codable(rejectUnknownKeys: true) @Inherits(decodable: false, encodable: false) class StrictNestedBase { @@ -282,6 +329,15 @@ struct StrictDecodingTests { ) } + @Test + func strictInternalEnumsRejectKeysFromOtherCases() throws { + try expectDataCorrupted( + StrictInternalCommand.self, + json: #"{"type":"load","key":"key","value":123}"#, + containing: ["value"] + ) + } + @Test func strictRecordRejectsUnknownKeysAndReportsAllKeys() throws { try expectDataCorrupted( @@ -298,6 +354,35 @@ struct StrictDecodingTests { ) } + @Test + func strictChildOfPermissiveInheritanceAcceptsBaseKeys() throws { + let data = Data(#"{"base":"base","child":"child"}"#.utf8) + let decoded = try JSONDecoder().decode( + PermissiveStrictChild.self, from: data + ) + #expect(decoded.base == "base") + #expect(decoded.child == "child") + } + + @Test + func strictMixedDirectAndKeyedRecordsAllowDelegatedKeys() throws { + let data = Data(#"{"name":"n","count":1}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictMixedDirectRecord.self, from: data + ) + #expect(decoded.payload.name == "n") + #expect(decoded.count == 1) + } + + @Test + func strictScalarClassHierarchiesRemainScalar() throws { + let data = Data(#""scalar""#.utf8) + let decoded = try JSONDecoder().decode( + StrictScalarChild.self, from: data + ) + #expect(decoded.value == "scalar") + } + @Test func strictRecordDecodesKnownKeys() throws { let data = Data( From 15de247ccbc356ee5cf81d47b6a6aa66a765390d Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 12:39:09 -0500 Subject: [PATCH 19/50] Refine delegated enum validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Switcher/AdjacentlyTaggableSwitcher.swift | 4 +++- .../Enum/Switcher/EnumSwitcherVariable.swift | 2 ++ ...pertyVariableTreeNode+CodingLocation.swift | 17 +++++++++++---- .../Tree/PropertyVariableTreeNode.swift | 9 +++++--- .../Variables/Type/EnumVariable.swift | 6 ++++-- .../StrictDecodingTests.swift | 21 +++++++++++++++++++ 6 files changed, 49 insertions(+), 10 deletions(-) diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift index 4c70b80c67..bd22556a66 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift @@ -245,7 +245,9 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { rejectUnknownKeys: location.rejectUnknownKeys, additionalAllowedKeys: location.caseDecodingSharesRootContainer - ? location.rootDecodingKeys : [] + ? location.rootDecodingKeys : [], + skipCurrentContainerValidation: + location.skipRootUnknownKeyValidation ) ).combined() diff --git a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift index 88d87fee54..e3a8be4d02 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift @@ -150,4 +150,6 @@ package struct EnumSwitcherLocation { let discriminatorDecodingKeys: [String] /// Whether associated case values share the enum root container. let caseDecodingSharesRootContainer: Bool + /// Whether root switcher validation is deferred for delegated cases. + let skipRootUnknownKeyValidation: Bool } diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift index 1432e12418..8d866c7cb8 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift @@ -16,6 +16,8 @@ extension PropertyVariableTreeNode { let deferUnknownKeyValidation: Bool /// Additional keys accepted by the current decoding container. let additionalAllowedKeys: [String] + /// Whether validation for the current root container is deferred. + let skipCurrentContainerValidation: Bool /// The decoding/encoding progress. /// /// All the decoded/encoded variables and @@ -33,13 +35,16 @@ extension PropertyVariableTreeNode { /// emitted after superclass decoding. private init( context: Context, progress: Progress, rejectUnknownKeys: Bool, - deferUnknownKeyValidation: Bool, additionalAllowedKeys: [String] + deferUnknownKeyValidation: Bool, additionalAllowedKeys: [String], + skipCurrentContainerValidation: Bool ) { self.context = context self.progress = progress self.rejectUnknownKeys = rejectUnknownKeys self.deferUnknownKeyValidation = deferUnknownKeyValidation self.additionalAllowedKeys = additionalAllowedKeys + self.skipCurrentContainerValidation = + skipCurrentContainerValidation } /// Add provided variable to list of already decoded/encoded variable. @@ -76,13 +81,16 @@ extension PropertyVariableTreeNode { _ coder: TokenSyntax, keyType: ExprSyntax, rejectUnknownKeys: Bool = false, deferUnknownKeyValidation: Bool = false, - additionalAllowedKeys: [String] = [] + additionalAllowedKeys: [String] = [], + skipCurrentContainerValidation: Bool = false ) -> Self { .init( context: .coder(coder, keyType: keyType), progress: .init(), rejectUnknownKeys: rejectUnknownKeys, deferUnknownKeyValidation: deferUnknownKeyValidation, - additionalAllowedKeys: additionalAllowedKeys + additionalAllowedKeys: additionalAllowedKeys, + skipCurrentContainerValidation: + skipCurrentContainerValidation ) } @@ -100,7 +108,8 @@ extension PropertyVariableTreeNode { context: .container(container, key: key), progress: progress, rejectUnknownKeys: rejectUnknownKeys, deferUnknownKeyValidation: deferUnknownKeyValidation, - additionalAllowedKeys: [] + additionalAllowedKeys: [], + skipCurrentContainerValidation: false ) } diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index a384f2c1fe..b3f4036e6b 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -77,6 +77,7 @@ extension PropertyVariableTreeNode { let strictValidation = location.rejectUnknownKeys && !location.deferUnknownKeyValidation + && !location.skipCurrentContainerValidation && !hasDirectDecodableVariables let allowedKeys: [String]? = if strictValidation { @@ -127,9 +128,11 @@ extension PropertyVariableTreeNode { location.rejectUnknownKeys && !location.deferUnknownKeyValidation ? nil : decodingContainer, - nestedContainerHasVariables: !self.children.lazy - .flatMap(\.value.variables) - .filter { $0.decode ?? true }.isEmpty, + nestedContainerHasVariables: + location.skipCurrentContainerValidation + || !self.children.lazy + .flatMap(\.value.variables) + .filter { $0.decode ?? true }.isEmpty, allowedKeys: allowedKeys ) { container in self.decodingContainer = container.name diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 037f130c12..bdbd3cee39 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -306,7 +306,8 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { discriminatorDecodingKeys: switcher.discriminatorDecodingKeys, caseDecodingSharesRootContainer: - switcher.caseDecodingSharesRootContainer + switcher.caseDecodingSharesRootContainer, + skipRootUnknownKeyValidation: hasDelegatedDirectCase ) code = CodeBlockItemListSyntax { if location.rejectUnknownKeys { @@ -380,7 +381,8 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { rejectUnknownKeys: false, rootDecodingKeys: [], discriminatorDecodingKeys: [], - caseDecodingSharesRootContainer: false + caseDecodingSharesRootContainer: false, + skipRootUnknownKeyValidation: false ) code = CodeBlockItemListSyntax { switcher.encoding(in: context, to: switcherLocation) diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 4649d0e9eb..8f27f85e83 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -250,6 +250,12 @@ struct StrictDecodingTests { case store(key: String, value: Int) } + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictDelegatedInternalCommand { + case item(StrictDecodingTests.PermissivePayload) + } + @Codable(rejectUnknownKeys: true) @CodedAt("type") @ContentAt("content") @@ -338,6 +344,21 @@ struct StrictDecodingTests { ) } + @Test + func strictInternalEnumsAllowDelegatedPayloadKeys() throws { + let data = Data( + #"{"type":"item","name":"n"}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictDelegatedInternalCommand.self, from: data + ) + if case .item(let value) = decoded { + #expect(value.name == "n") + } else { + Issue.record("Expected delegated item case") + } + } + @Test func strictRecordRejectsUnknownKeysAndReportsAllKeys() throws { try expectDataCorrupted( From 23cc0792e053c7e245b2837dd7cac0d1c7d68743 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 12:45:08 -0500 Subject: [PATCH 20/50] Diagnose unsupported strict decoding arguments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Expansion/AttributeExpander.swift | 22 +++++++++++++- .../Expansion/Options/Options.swift | 29 +++++++++++-------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/Sources/PluginCore/Expansion/AttributeExpander.swift b/Sources/PluginCore/Expansion/AttributeExpander.swift index cf33196638..b132a30209 100644 --- a/Sources/PluginCore/Expansion/AttributeExpander.swift +++ b/Sources/PluginCore/Expansion/AttributeExpander.swift @@ -1,5 +1,6 @@ import SwiftSyntax import SwiftSyntaxBuilder +import SwiftDiagnostics import SwiftSyntaxMacros /// A type managing registrations of variable and `CodingKey` data. @@ -37,7 +38,26 @@ struct AttributeExpander { let typeVar = variable as? any TypeVariable else { return nil } self.variable = typeVar - self.options = .init(for: declaration) + let options = Options(for: declaration) + if let invalid = options.invalidRejectUnknownKeysArgument { + context.diagnose( + .init( + node: invalid, + message: MetaCodableMessage( + macro: invalid, + message: + "rejectUnknownKeys must be a Boolean literal " + + "or a parenthesized/prefix Boolean literal.", + messageID: Codable.messageID( + "invalid-reject-unknown-keys" + ), + severity: .error + ), + fixIts: [] + ) + ) + } + self.options = options } /// Generates extension declarations for `Codable` macro. diff --git a/Sources/PluginCore/Expansion/Options/Options.swift b/Sources/PluginCore/Expansion/Options/Options.swift index ac29ba6ca2..f9f3ee9ce2 100644 --- a/Sources/PluginCore/Expansion/Options/Options.swift +++ b/Sources/PluginCore/Expansion/Options/Options.swift @@ -16,6 +16,8 @@ extension AttributeExpander { let availableAttributes: [AttributeSyntax] /// Whether generated decoding rejects unsupported keys. let rejectUnknownKeys: Bool + /// The invalid strict-decoding argument, if one was provided. + let invalidRejectUnknownKeysArgument: AttributeSyntax? /// Memberwise initialization generator with provided options. /// @@ -45,18 +47,21 @@ extension AttributeExpander { return nil } } - self.rejectUnknownKeys = - decl.attributes.compactMap { attribute in - guard - case .attribute(let attr) = attribute, - let arguments = attr.arguments? - .as(LabeledExprListSyntax.self), - let argument = arguments.first(where: { - $0.label?.text == "rejectUnknownKeys" - }) - else { return nil } - return Self.booleanValue(argument.expression) - }.first ?? false + let strictArguments = decl.attributes.compactMap { + attribute -> (AttributeSyntax, Bool?)? in + guard + case .attribute(let attr) = attribute, + let arguments = attr.arguments? + .as(LabeledExprListSyntax.self), + let argument = arguments.first(where: { + $0.label?.text == "rejectUnknownKeys" + }) + else { return nil } + return (attr, Self.booleanValue(argument.expression)) + } + self.rejectUnknownKeys = strictArguments.first?.1 ?? false + self.invalidRejectUnknownKeysArgument = + strictArguments.first { $0.1 == nil }?.0 } /// Resolves boolean expressions supported by the macro argument. From 63f183b1b75005d58651cd3d524ca2777657d278 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 12:51:33 -0500 Subject: [PATCH 21/50] Update inherited decoding expansion snapshot Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tests/MetaCodableTests/CodableTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Tests/MetaCodableTests/CodableTests.swift b/Tests/MetaCodableTests/CodableTests.swift index 81f8168f8f..ad46aaa59b 100644 --- a/Tests/MetaCodableTests/CodableTests.swift +++ b/Tests/MetaCodableTests/CodableTests.swift @@ -447,9 +447,10 @@ struct CodableTests { } required init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let __macro_local_13strictDecoderfMu_ = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) + let container = try __macro_local_13strictDecoderfMu_.container(keyedBy: CodingKeys.self) self.value = try container.decode(String.self, forKey: CodingKeys.value) - try super.init(from: decoder) + try super.init(from: __macro_local_13strictDecoderfMu_) } override func encode(to encoder: any Encoder) throws { From aa2186651463b1a689d3c3d70c8bda1e060397bf Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 13:02:49 -0500 Subject: [PATCH 22/50] Validate inherited keys for empty strict subclasses Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 10 ++++++ .../Variables/Type/ClassVariable.swift | 3 +- .../StrictDecodingTests.swift | 31 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index 961fb1ae0e..21470756b3 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -43,6 +43,7 @@ public struct MetaCodableStrictDecoder: Decoder { keyedBy type: Key.Type ) throws -> KeyedDecodingContainer { let container = try base.container(keyedBy: type) + state.recordContainer(at: container.codingPath) return KeyedDecodingContainer( StrictKeyedDecodingContainer(base: container, state: state) ) @@ -91,6 +92,10 @@ public struct MetaCodableStrictDecoder: Decoder { observedContainerPaths.contains(path) } + func hasContainer(at path: [any CodingKey]) -> Bool { + hasContainerPath(self.path(for: path)) + } + func additionalKeys(at path: [any CodingKey]) -> Set { let path = self.path(for: path) return (allowedKeysByPath[path] ?? []).union( @@ -306,6 +311,11 @@ public extension Decoder { allowedKeysByPath: [String: [String]], type: Any.Type ) throws { + if let strictDecoder = self as? MetaCodableStrictDecoder, + !strictDecoder.state.hasContainer(at: codingPath) + { + return + } let rawContainer = try container( keyedBy: DynamicCodableIdentifier.self ) diff --git a/Sources/PluginCore/Variables/Type/ClassVariable.swift b/Sources/PluginCore/Variables/Type/ClassVariable.swift index dd15f93319..22985bfa9b 100644 --- a/Sources/PluginCore/Variables/Type/ClassVariable.swift +++ b/Sources/PluginCore/Variables/Type/ClassVariable.swift @@ -157,7 +157,6 @@ struct ClassVariable: TypeVariable, DeclaredVariable { let generated = group.decoding(in: context, from: newLocation) else { return nil } let metadataMap = group.decodingNode.allowedKeysByPath() - let hasKeyedMetadata = metadataMap.values.contains { !$0.isEmpty } var code: CodeBlockItemListSyntax if overridden { let metadata = strictDecodingMetadataSyntax( @@ -167,7 +166,7 @@ struct ClassVariable: TypeVariable, DeclaredVariable { "let \(strictDecoder) = MetaCodableStrictDecoder(decoder: \(method.arg), allowedKeysByPath: \(metadata))" generated.code "try super.\(method.name)(\(method.argLabel): \(strictDecoder))" - if location.rejectUnknownKeys, hasKeyedMetadata { + if location.rejectUnknownKeys { "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" } } diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 8f27f85e83..901954e707 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -213,6 +213,22 @@ struct StrictDecodingTests { } } + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveEmptyBase { + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictEmptyChild: PermissiveEmptyBase { + override init() { + super.init() + } + } + @Codable @Inherits(decodable: false, encodable: false) class PermissiveNestedBase { @@ -670,6 +686,21 @@ struct StrictDecodingTests { #expect(decoded.child == "child") } + @Test + func strictEmptyChildValidatesInheritedKeyedContainers() throws { + let data = Data(#"{"base":"base"}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictEmptyChild.self, from: data + ) + #expect(decoded.base == "base") + + try expectDataCorrupted( + StrictEmptyChild.self, + json: #"{"base":"base","extra":true}"#, + containing: ["extra"] + ) + } + @Test func strictChildOfPermissiveClassValidatesInheritedNestedContainers() throws { From 7726c1a08e23f3da6b7e7147a9caefac508a40fa Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 13:19:07 -0500 Subject: [PATCH 23/50] Preserve strict decoder terminal scopes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 35 ++++++--- .../StrictDecodingTests.swift | 72 +++++++++++++++++++ 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index 21470756b3..6901c9c25c 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -69,6 +69,7 @@ public struct MetaCodableStrictDecoder: Decoder { var allowedKeysByPath: [String: Set] var observedKeysByPath: [String: Set] = [:] var observedContainerPaths: Set = [] + var terminalDecoderPaths: Set = [] let rootCodingPath: [String] init( @@ -88,12 +89,16 @@ public struct MetaCodableStrictDecoder: Decoder { observedContainerPaths.insert(self.path(for: path)) } + func recordTerminalDecoder(at path: [any CodingKey]) { + terminalDecoderPaths.insert(self.path(for: path)) + } + func hasContainerPath(_ path: String) -> Bool { observedContainerPaths.contains(path) } - func hasContainer(at path: [any CodingKey]) -> Bool { - hasContainerPath(self.path(for: path)) + func hasTerminalDecoder(at path: [any CodingKey]) -> Bool { + terminalDecoderPaths.contains(self.path(for: path)) } func additionalKeys(at path: [any CodingKey]) -> Set { @@ -264,9 +269,9 @@ private struct StrictKeyedDecodingContainer: func superDecoder(forKey key: Key) throws -> any Decoder { state.record(key: key, at: codingPath) - return MetaCodableStrictDecoder( - base: try base.superDecoder(forKey: key), state: state - ) + let decoder = try base.superDecoder(forKey: key) + state.recordTerminalDecoder(at: decoder.codingPath) + return MetaCodableStrictDecoder(base: decoder, state: state) } } @@ -311,14 +316,17 @@ public extension Decoder { allowedKeysByPath: [String: [String]], type: Any.Type ) throws { - if let strictDecoder = self as? MetaCodableStrictDecoder, - !strictDecoder.state.hasContainer(at: codingPath) - { + let rawContainer: + KeyedDecodingContainer< + DynamicCodableIdentifier + > + do { + rawContainer = try container( + keyedBy: DynamicCodableIdentifier.self + ) + } catch DecodingError.typeMismatch { return } - let rawContainer = try container( - keyedBy: DynamicCodableIdentifier.self - ) try rawContainer.rejectUnknownKeys( atPath: "", allowedKeysByPath: allowedKeysByPath, @@ -356,6 +364,11 @@ where Key == DynamicCodableIdentifier { } for key in allKeys { + if state?.hasTerminalDecoder( + at: codingPath + [key] + ) == true { + continue + } let childPath = state?.path(for: codingPath + [key]) ?? path guard diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 901954e707..fecd7ba3ec 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -22,6 +22,14 @@ struct StrictDecodingTests { let name: String } + struct PermissivePayloadCoder: HelperCoder { + typealias Coded = PermissivePayload + + func decode(from decoder: Decoder) throws -> PermissivePayload { + try PermissivePayload(from: decoder) + } + } + @Codable(rejectUnknownKeys: true) struct StrictMixedDirectRecord { @CodedAt @@ -229,6 +237,39 @@ struct StrictDecodingTests { } } + @Codable + @Inherits(decodable: false, encodable: false) + class EmptyPermissiveBase { + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictEmptyHierarchyChild: EmptyPermissiveBase { + override init() { + super.init() + } + } + + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveHelperBase { + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictHelperChild: PermissiveHelperBase { + @CodedBy(PermissivePayloadCoder()) + var payload: PermissivePayload = .init(name: "") + + override init() { + super.init() + } + } + @Codable @Inherits(decodable: false, encodable: false) class PermissiveNestedBase { @@ -701,6 +742,37 @@ struct StrictDecodingTests { ) } + @Test + func strictEmptyHierarchyChildRejectsObjectKeys() throws { + _ = try JSONDecoder().decode( + StrictEmptyHierarchyChild.self, + from: Data(#"{}"#.utf8) + ) + + try expectDataCorrupted( + StrictEmptyHierarchyChild.self, + json: #"{"extra":true}"#, + containing: ["extra"] + ) + } + + @Test + func strictHelperPayloadOwnsItsNestedKeys() throws { + let data = Data( + #"{"base":"base","payload":{"name":"name","extra":true}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictHelperChild.self, from: data + ) + #expect(decoded.payload.name == "name") + + try expectDataCorrupted( + StrictHelperChild.self, + json: #"{"base":"base","payload":{"name":"name"},"extra":true}"#, + containing: ["extra"] + ) + } + @Test func strictChildOfPermissiveClassValidatesInheritedNestedContainers() throws { From 05fa179479b6d68bb8e603fe252bd019d4268284 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 13:38:13 -0500 Subject: [PATCH 24/50] Preserve structural validation scopes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 16 ++++- .../StrictDecodingTests.swift | 65 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index 6901c9c25c..7ab86cd42f 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -93,6 +93,10 @@ public struct MetaCodableStrictDecoder: Decoder { terminalDecoderPaths.insert(self.path(for: path)) } + func clearTerminalDecoder(at path: [any CodingKey]) { + terminalDecoderPaths.remove(self.path(for: path)) + } + func hasContainerPath(_ path: String) -> Bool { observedContainerPaths.contains(path) } @@ -288,6 +292,9 @@ public extension Decoder { allowedKeys: [String], type: Any.Type ) throws { + if let strictDecoder = self as? MetaCodableStrictDecoder { + strictDecoder.state.clearTerminalDecoder(at: codingPath) + } let rawContainer = try container( keyedBy: DynamicCodableIdentifier.self ) @@ -324,8 +331,13 @@ public extension Decoder { rawContainer = try container( keyedBy: DynamicCodableIdentifier.self ) - } catch DecodingError.typeMismatch { - return + } catch let error as DecodingError { + switch error { + case .typeMismatch, .valueNotFound: + return + default: + throw error + } } try rawContainer.rejectUnknownKeys( atPath: "", diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index fecd7ba3ec..0696d0da19 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -172,6 +172,23 @@ struct StrictDecodingTests { } } + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictOptionalScalarBase { + @CodedAt + var value: String? = nil + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictOptionalScalarChild: StrictOptionalScalarBase { + override init() { + super.init() + } + } + @Codable(rejectUnknownKeys: true) @Inherits(decodable: false, encodable: false) class StrictNestedBase { @@ -192,6 +209,26 @@ struct StrictDecodingTests { } } + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictStructuralBase { + @CodedIn("payload") + var base: String = "" + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictStructuralChild: StrictStructuralBase { + @CodedIn("payload", "details") + var value: String = "" + + override init() { + super.init() + } + } + @Codable(rejectUnknownKeys: true) @Inherits(decodable: true, encodable: true) class StrictNestedGrandChild: StrictNestedChild { @@ -461,6 +498,15 @@ struct StrictDecodingTests { #expect(decoded.value == "scalar") } + @Test + func strictOptionalScalarClassHierarchiesAllowNull() throws { + let decoded = try JSONDecoder().decode( + StrictOptionalScalarChild.self, + from: Data("null".utf8) + ) + #expect(decoded.value == nil) + } + @Test func strictRecordDecodesKnownKeys() throws { let data = Data( @@ -685,6 +731,25 @@ struct StrictDecodingTests { #expect(decoded.child == "child") } + @Test + func strictStructuralInheritanceDoesNotSkipDeeperPaths() throws { + try expectDataCorrupted( + StrictStructuralChild.self, + json: """ + { + "payload": { + "base": "base", + "details": { + "value": "value", + "extra": true + } + } + } + """, + containing: ["extra"] + ) + } + @Test func strictNestedClassValidationNormalizesEnclosingCodingPaths() throws { try expectDataCorrupted( From 8171cba425aee3f374cfbc1efb77521d229e66dd Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 14:45:51 -0500 Subject: [PATCH 25/50] Harden strict decoder inheritance scopes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 22 ++- .../Enum/Case/BasicEnumCaseVariable.swift | 16 +- .../Switcher/AdjacentlyTaggableSwitcher.swift | 7 + .../AdjacentlyTaggedEnumSwitcher.swift | 34 +++- .../Enum/Switcher/AnyEnumSwitcher.swift | 4 + .../Enum/Switcher/EnumSwitcherVariable.swift | 10 ++ .../InternallyTaggedEnumSwitcher.swift | 6 + .../Switcher/TaggedEnumSwitcherVariable.swift | 3 +- ...pertyVariableTreeNode+CodingLocation.swift | 31 +++- .../Tree/PropertyVariableTreeNode.swift | 3 + .../Variables/Type/EnumVariable.swift | 7 + .../StrictDecodingTests.swift | 145 ++++++++++++++++++ 12 files changed, 277 insertions(+), 11 deletions(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index 7ab86cd42f..b3377b2edf 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -17,8 +17,16 @@ public struct MetaCodableStrictDecoder: Decoder { ) { if let decoder = decoder as? MetaCodableStrictDecoder { self.base = decoder.base - self.state = decoder.state - self.state.merge(allowedKeysByPath: allowedKeysByPath) + let codingPath = decoder.codingPath.map(\.stringValue) + if codingPath == decoder.state.rootCodingPath { + self.state = decoder.state + self.state.merge(allowedKeysByPath: allowedKeysByPath) + } else { + self.state = .init( + allowedKeysByPath: allowedKeysByPath, + rootCodingPath: codingPath + ) + } } else { self.base = decoder self.state = .init( @@ -266,9 +274,13 @@ private struct StrictKeyedDecodingContainer: } func superDecoder() throws -> any Decoder { - MetaCodableStrictDecoder( - base: try base.superDecoder(), state: state - ) + let decoder = try base.superDecoder() + if decoder.codingPath.count > codingPath.count, + let key = decoder.codingPath.last + { + state.record(key: key, at: codingPath) + } + return MetaCodableStrictDecoder(base: decoder, state: state) } func superDecoder(forKey key: Key) throws -> any Decoder { diff --git a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift index 07bbe6f70d..83d0c86a6f 100644 --- a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift @@ -182,15 +182,29 @@ struct BasicEnumCaseVariable: EnumCaseVariable { } } ) + let directDecoder = context.makeUniqueName("strictDecoder") + let usesStrictDirectDecoder = + location.rejectUnknownKeys + && !variables.isEmpty + && !usesKeyedDecoding + && !location.additionalAllowedKeys.isEmpty + let coder = + usesStrictDirectDecoder ? directDecoder : location.coder let generated = decodingNode.decoding( with: decodingData, in: context, from: .withCoder( - location.coder, keyType: codingKeys.type, + coder, keyType: codingKeys.type, rejectUnknownKeys: location.rejectUnknownKeys, additionalAllowedKeys: location.additionalAllowedKeys ) ) let newSyntax = CodeBlockItemListSyntax { + if usesStrictDirectDecoder { + let metadata = strictDecodingMetadataSyntax( + ["": location.additionalAllowedKeys] + ) + "let \(directDecoder) = MetaCodableStrictDecoder(decoder: \(location.coder), allowedKeysByPath: \(metadata))" + } for variable in variables where variable.decode ?? true { "let \(variable.name): \(variable.type)" } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift index bd22556a66..49c87394fa 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggableSwitcher.swift @@ -7,6 +7,9 @@ import SwiftSyntaxMacros /// Only internally tagged enums can have adjacent tagging, while externally /// tagged enums can't have adjacent tagging. protocol AdjacentlyTaggableSwitcher: EnumSwitcherVariable { + /// The discriminator path used by the wrapped switcher. + var discriminatorDecodingPath: [String] { get } + /// Provides the syntax for decoding at the provided location and decoder. /// /// The generated implementation decodes the identifier variable from @@ -246,6 +249,10 @@ extension InternallyTaggedEnumSwitcher: AdjacentlyTaggableSwitcher { additionalAllowedKeys: location.caseDecodingSharesRootContainer ? location.rootDecodingKeys : [], + additionalAllowedKeysByPath: + location.additionalAllowedKeysByPath, + skipUnknownKeyValidationPaths: + location.skipUnknownKeyValidationPaths, skipCurrentContainerValidation: location.skipRootUnknownKeyValidation ) diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift index 9c134c29fb..599624d0fc 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift @@ -30,6 +30,19 @@ where Wrapped: AdjacentlyTaggableSwitcher { } /// Adjacent case values are decoded from the content container. var caseDecodingSharesRootContainer: Bool { false } + /// Keys from a nested discriminator container accepted by direct cases. + var caseDecodingAdditionalAllowedKeys: [String] { + guard + base.discriminatorDecodingPath.count > contentDecodingPath.count, + Array( + base.discriminatorDecodingPath.dropLast() + ) == contentDecodingPath, + let key = base.discriminatorDecodingPath.last + else { return [] } + return [key] + } + /// The path used for adjacent case content. + let contentDecodingPath: [String] /// Creates switcher variable with provided data. /// @@ -51,6 +64,7 @@ where Wrapped: AdjacentlyTaggableSwitcher { let encodingKeys = codingKeys.add( keys: keyPath.encoding, context: context) self.variable = .init(decoder: contentDecoder, encoder: contentEncoder) + self.contentDecodingPath = keyPath.decoding base.registering(variable: variable, decodingKeyPath: decodingKeys) base.registering(variable: variable, encodingKeyPath: encodingKeys) self.base = base @@ -120,7 +134,25 @@ where Wrapped: AdjacentlyTaggableSwitcher { in context: some MacroExpansionContext, from location: EnumSwitcherLocation ) -> CodeBlockItemListSyntax { - base.decoding( + var location = location + if base.discriminatorDecodingPath.count + > contentDecodingPath.count, + Array( + base.discriminatorDecodingPath.dropLast() + ) == contentDecodingPath + { + let path = strictDecodingPathKey(contentDecodingPath) + let caseKeys = location.cases.flatMap { + $0.variable.decodingAllowedKeys + } + if !caseKeys.isEmpty { + location.additionalAllowedKeysByPath[path] = caseKeys + } + if location.skipRootUnknownKeyValidation { + location.skipUnknownKeyValidationPaths.insert(path) + } + } + return base.decoding( in: context, from: location, contentAt: variable.decoder ) } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift index 023dc4f5dd..1c8389f333 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift @@ -21,6 +21,10 @@ struct AnyEnumSwitcher: EnumSwitcherVariable { var discriminatorDecodingKeys: [String] { base.discriminatorDecodingKeys } + /// Additional keys accepted by direct case decoders. + var caseDecodingAdditionalAllowedKeys: [String] { + base.caseDecodingAdditionalAllowedKeys + } /// Whether case values share the wrapped switcher's root container. var caseDecodingSharesRootContainer: Bool { base.caseDecodingSharesRootContainer diff --git a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift index e3a8be4d02..5ac1f761c2 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift @@ -15,6 +15,8 @@ where var rootDecodingKeys: [String]? { get } /// Keys used by the switcher itself before case values are decoded. var discriminatorDecodingKeys: [String] { get } + /// Additional keys accepted by direct case decoders. + var caseDecodingAdditionalAllowedKeys: [String] { get } /// Whether case values are decoded in the same root container. var caseDecodingSharesRootContainer: Bool { get } /// Whether some cases may decode directly from scalar or unkeyed values. @@ -80,6 +82,8 @@ extension EnumSwitcherVariable { var rootDecodingKeys: [String]? { nil } /// Switchers have no separate discriminator keys by default. var discriminatorDecodingKeys: [String] { [] } + /// Direct case decoders have no additional keys by default. + var caseDecodingAdditionalAllowedKeys: [String] { [] } /// Case values are nested unless a switcher opts into sharing the root. var caseDecodingSharesRootContainer: Bool { false } /// Switchers do not mix direct and keyed representations by default. @@ -150,6 +154,12 @@ package struct EnumSwitcherLocation { let discriminatorDecodingKeys: [String] /// Whether associated case values share the enum root container. let caseDecodingSharesRootContainer: Bool + /// Additional keys accepted by direct case decoders. + var caseDecodingAdditionalAllowedKeys: [String] + /// Additional keys accepted at nested decoding container paths. + var additionalAllowedKeysByPath: [String: [String]] + /// Paths whose generated validation is deferred. + var skipUnknownKeyValidationPaths: Set /// Whether root switcher validation is deferred for delegated cases. let skipRootUnknownKeyValidation: Bool } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift index b1a17205c5..e2fed87d19 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift @@ -20,6 +20,12 @@ where var discriminatorDecodingKeys: [String] { Array(decodingNode.allowedKeys) } + /// The discriminator path used for internal tagging. + var discriminatorDecodingPath: [String] { keyPath.decoding } + /// Internally tagged direct cases share discriminator keys. + var caseDecodingAdditionalAllowedKeys: [String] { + discriminatorDecodingKeys + } /// Internally tagged case values share the discriminator container. var caseDecodingSharesRootContainer: Bool { true } /// The identifier variable build action type. diff --git a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift index c9123b3d8b..716361d87f 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/TaggedEnumSwitcherVariable.swift @@ -55,8 +55,7 @@ extension TaggedEnumSwitcherVariable { coder: coder, values: values, rejectUnknownKeys: location.rejectUnknownKeys, additionalAllowedKeys: - location.caseDecodingSharesRootContainer - ? location.discriminatorDecodingKeys : [] + location.caseDecodingAdditionalAllowedKeys ) let generated = `case`.decoding( in: context, from: cLocation diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift index 8d866c7cb8..c869943aa8 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift @@ -16,6 +16,12 @@ extension PropertyVariableTreeNode { let deferUnknownKeyValidation: Bool /// Additional keys accepted by the current decoding container. let additionalAllowedKeys: [String] + /// Additional keys accepted at nested decoding container paths. + let additionalAllowedKeysByPath: [String: [String]] + /// The coding path represented by the current decoding location. + let codingPath: [String] + /// Paths whose generated validation is deferred. + let skipUnknownKeyValidationPaths: Set /// Whether validation for the current root container is deferred. let skipCurrentContainerValidation: Bool /// The decoding/encoding progress. @@ -36,6 +42,9 @@ extension PropertyVariableTreeNode { private init( context: Context, progress: Progress, rejectUnknownKeys: Bool, deferUnknownKeyValidation: Bool, additionalAllowedKeys: [String], + additionalAllowedKeysByPath: [String: [String]], + codingPath: [String], + skipUnknownKeyValidationPaths: Set, skipCurrentContainerValidation: Bool ) { self.context = context @@ -43,6 +52,10 @@ extension PropertyVariableTreeNode { self.rejectUnknownKeys = rejectUnknownKeys self.deferUnknownKeyValidation = deferUnknownKeyValidation self.additionalAllowedKeys = additionalAllowedKeys + self.additionalAllowedKeysByPath = additionalAllowedKeysByPath + self.codingPath = codingPath + self.skipUnknownKeyValidationPaths = + skipUnknownKeyValidationPaths self.skipCurrentContainerValidation = skipCurrentContainerValidation } @@ -82,6 +95,8 @@ extension PropertyVariableTreeNode { rejectUnknownKeys: Bool = false, deferUnknownKeyValidation: Bool = false, additionalAllowedKeys: [String] = [], + additionalAllowedKeysByPath: [String: [String]] = [:], + skipUnknownKeyValidationPaths: Set = [], skipCurrentContainerValidation: Bool = false ) -> Self { .init( @@ -89,6 +104,10 @@ extension PropertyVariableTreeNode { rejectUnknownKeys: rejectUnknownKeys, deferUnknownKeyValidation: deferUnknownKeyValidation, additionalAllowedKeys: additionalAllowedKeys, + additionalAllowedKeysByPath: additionalAllowedKeysByPath, + codingPath: [], + skipUnknownKeyValidationPaths: + skipUnknownKeyValidationPaths, skipCurrentContainerValidation: skipCurrentContainerValidation ) @@ -104,11 +123,19 @@ extension PropertyVariableTreeNode { func withContainer( _ container: Context.Container, key: CodingKeysMap.Key ) -> Self { - .init( + let codingPath = codingPath + [key.description] + return .init( context: .container(container, key: key), progress: progress, rejectUnknownKeys: rejectUnknownKeys, deferUnknownKeyValidation: deferUnknownKeyValidation, - additionalAllowedKeys: [], + additionalAllowedKeys: + additionalAllowedKeysByPath[ + strictDecodingPathKey(codingPath) + ] ?? [], + additionalAllowedKeysByPath: additionalAllowedKeysByPath, + codingPath: codingPath, + skipUnknownKeyValidationPaths: + skipUnknownKeyValidationPaths, skipCurrentContainerValidation: false ) } diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index b3f4036e6b..a7eaa2504b 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -78,6 +78,9 @@ extension PropertyVariableTreeNode { location.rejectUnknownKeys && !location.deferUnknownKeyValidation && !location.skipCurrentContainerValidation + && !location.skipUnknownKeyValidationPaths.contains( + strictDecodingPathKey(location.codingPath) + ) && !hasDirectDecodableVariables let allowedKeys: [String]? = if strictValidation { diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index bdbd3cee39..ce008b71bc 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -307,6 +307,10 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { switcher.discriminatorDecodingKeys, caseDecodingSharesRootContainer: switcher.caseDecodingSharesRootContainer, + caseDecodingAdditionalAllowedKeys: + switcher.caseDecodingAdditionalAllowedKeys, + additionalAllowedKeysByPath: [:], + skipUnknownKeyValidationPaths: [], skipRootUnknownKeyValidation: hasDelegatedDirectCase ) code = CodeBlockItemListSyntax { @@ -382,6 +386,9 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { rootDecodingKeys: [], discriminatorDecodingKeys: [], caseDecodingSharesRootContainer: false, + caseDecodingAdditionalAllowedKeys: [], + additionalAllowedKeysByPath: [:], + skipUnknownKeyValidationPaths: [], skipRootUnknownKeyValidation: false ) code = CodeBlockItemListSyntax { diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 0696d0da19..0071c555d3 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -30,6 +30,65 @@ struct StrictDecodingTests { } } + @Test + func strictInternalEnumsAllowDiscriminatorKeysInStrictDirectPayloads() + throws + { + let data = Data( + #"{"type":"item","name":"n","count":1}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictStrictDelegatedInternalCommand.self, + from: data + ) + if case .item(let value) = decoded { + #expect(value.name == "n") + #expect(value.count == 1) + } else { + Issue.record("Expected strict delegated item case") + } + } + + @Test + func strictNestedAdjacentEnumsAllowDelegatedPayloadKeys() throws { + let data = Data( + #"{"meta":{"kind":"item","name":"n"}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictNestedAdjacentCommand.self, + from: data + ) + if case .item(let value) = decoded { + #expect(value.name == "n") + } else { + Issue.record("Expected nested adjacent item case") + } + } + + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveNestedClassBase { + var nestedOnly: String = "" + + init() {} + } + + @Codable + @Inherits(decodable: true, encodable: true) + class PermissiveNestedClass: PermissiveNestedClassBase { + override init() { + super.init() + } + } + + struct PermissiveNestedClassCoder: HelperCoder { + typealias Coded = PermissiveNestedClass + + func decode(from decoder: Decoder) throws -> PermissiveNestedClass { + try PermissiveNestedClass(from: decoder) + } + } + @Codable(rejectUnknownKeys: true) struct StrictMixedDirectRecord { @CodedAt @@ -307,6 +366,48 @@ struct StrictDecodingTests { } } + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictNestedScopeBase { + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictNestedScopeChild: StrictNestedScopeBase { + @CodedBy(PermissiveNestedClassCoder()) + var payload: PermissiveNestedClass = .init() + + override init() { + super.init() + } + } + + struct ManualSuperPayload: Swift.Decodable { + let value: String + } + + class ManualSuper: Swift.Decodable { + var superValue: String = "" + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: ManualKeys.self) + superValue = try ManualSuperPayload( + from: container.superDecoder() + ).value + } + + enum ManualKeys: String, CodingKey { + case superValue = "super" + } + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: false) + class StrictManualSuperChild: ManualSuper { + var child: String = "" + } + @Codable @Inherits(decodable: false, encodable: false) class PermissiveNestedBase { @@ -350,6 +451,12 @@ struct StrictDecodingTests { case item(StrictDecodingTests.PermissivePayload) } + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictStrictDelegatedInternalCommand { + case item(StrictDecodingTests.StrictRecord) + } + @Codable(rejectUnknownKeys: true) @CodedAt("type") @ContentAt("content") @@ -379,6 +486,13 @@ struct StrictDecodingTests { case number(value: Int) } + @Codable(rejectUnknownKeys: true) + @CodedAt("meta", "kind") + @ContentAt("meta") + enum StrictNestedAdjacentCommand { + case item(StrictDecodingTests.PermissivePayload) + } + @Codable(rejectUnknownKeys: true) enum StrictRawCommand: String { case ready @@ -535,6 +649,37 @@ struct StrictDecodingTests { ) } + @Test + func strictNestedInheritedHelperUsesItsOwnKeyScope() throws { + let validData = Data( + #"{"payload":{"nestedOnly":"nested"}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictNestedScopeChild.self, + from: validData + ) + #expect(decoded.payload.nestedOnly == "nested") + + try expectDataCorrupted( + StrictNestedScopeChild.self, + json: #"{"payload":{"nestedOnly":"nested"},"nestedOnly":"root"}"#, + containing: ["nestedOnly"] + ) + } + + @Test + func strictManualSuperclassSuperDecoderKeysRemainSupported() throws { + let data = Data( + #"{"super":{"value":"super"},"child":"child"}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictManualSuperChild.self, + from: data + ) + #expect(decoded.superValue == "super") + #expect(decoded.child == "child") + } + @Test func permissiveDecodingRemainsUnchanged() throws { let data = Data( From 57e17a5994d4057f22dc3dc453125f3946b8acf7 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 15:00:13 -0500 Subject: [PATCH 26/50] Allow deeper adjacent discriminator paths Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AdjacentlyTaggedEnumSwitcher.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift index 599624d0fc..675e917c43 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift @@ -34,10 +34,14 @@ where Wrapped: AdjacentlyTaggableSwitcher { var caseDecodingAdditionalAllowedKeys: [String] { guard base.discriminatorDecodingPath.count > contentDecodingPath.count, - Array( - base.discriminatorDecodingPath.dropLast() - ) == contentDecodingPath, - let key = base.discriminatorDecodingPath.last + base.discriminatorDecodingPath.starts( + with: contentDecodingPath + ) + else { return [] } + let relativePath = base.discriminatorDecodingPath.dropFirst( + contentDecodingPath.count + ) + guard let key = relativePath.first else { return [] } return [key] } @@ -137,9 +141,9 @@ where Wrapped: AdjacentlyTaggableSwitcher { var location = location if base.discriminatorDecodingPath.count > contentDecodingPath.count, - Array( - base.discriminatorDecodingPath.dropLast() - ) == contentDecodingPath + base.discriminatorDecodingPath.starts( + with: contentDecodingPath + ) { let path = strictDecodingPathKey(contentDecodingPath) let caseKeys = location.cases.flatMap { From 07ffa6e8956ee35df0b5783086f34876ef498f6e Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 15:22:35 -0500 Subject: [PATCH 27/50] Refresh Copilot review after resolving findings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> From 5b4079de28f9647cac059e98ba3bd24f1761e230 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 16:03:20 -0500 Subject: [PATCH 28/50] Harden optional and mixed enum payload decoding Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 12 ++++ .../Enum/Case/BasicEnumCaseVariable.swift | 20 ++++-- .../Enum/Case/EnumCaseVariable.swift | 4 ++ .../AdjacentlyTaggedEnumSwitcher.swift | 6 +- .../Enum/Switcher/AnyEnumSwitcher.swift | 2 + .../Enum/Switcher/EnumSwitcherVariable.swift | 4 ++ .../InternallyTaggedEnumSwitcher.swift | 6 +- .../Property/BasicPropertyVariable.swift | 24 ++++++- .../Property/DefaultValueVariable.swift | 2 + .../Property/HelperCodedVariable.swift | 16 +++-- .../Variables/Property/PropertyVariable.swift | 3 + ...pertyVariableTreeNode+CodingLocation.swift | 22 ++++++ .../Tree/PropertyVariableTreeNode.swift | 2 +- .../Variables/Type/EnumVariable.swift | 26 ++++--- .../StrictDecodingTests.swift | 71 +++++++++++++++++++ 15 files changed, 191 insertions(+), 29 deletions(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index b3377b2edf..5811b39fe8 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -36,6 +36,18 @@ public struct MetaCodableStrictDecoder: Decoder { } } + /// Decodes an optional value while preserving strict-decoding metadata. + public static func decodeOptional( + _ type: Value.Type, + from decoder: any Decoder + ) throws -> Value? { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + return nil + } + return try Value(from: decoder) + } + fileprivate init(base: any Decoder, state: State) { self.base = base self.state = state diff --git a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift index 83d0c86a6f..1370d8c44c 100644 --- a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift @@ -73,6 +73,10 @@ struct BasicEnumCaseVariable: EnumCaseVariable { var usesKeyedDecoding: Bool { variables.contains { $0.label != nil } } + /// Whether associated values include an unlabeled direct value. + var usesDirectDecoding: Bool { + variables.contains { $0.label == nil } + } /// Creates a new enum-case variable from provided data. /// @@ -183,11 +187,15 @@ struct BasicEnumCaseVariable: EnumCaseVariable { } ) let directDecoder = context.makeUniqueName("strictDecoder") + var directAllowedKeys = location.additionalAllowedKeys + for key in decodingAllowedKeys + where !directAllowedKeys.contains(key) { + directAllowedKeys.append(key) + } let usesStrictDirectDecoder = location.rejectUnknownKeys - && !variables.isEmpty - && !usesKeyedDecoding - && !location.additionalAllowedKeys.isEmpty + && usesDirectDecoding + && !directAllowedKeys.isEmpty let coder = usesStrictDirectDecoder ? directDecoder : location.coder let generated = decodingNode.decoding( @@ -195,13 +203,15 @@ struct BasicEnumCaseVariable: EnumCaseVariable { from: .withCoder( coder, keyType: codingKeys.type, rejectUnknownKeys: location.rejectUnknownKeys, - additionalAllowedKeys: location.additionalAllowedKeys + additionalAllowedKeys: location.additionalAllowedKeys, + preserveDecoderMetadata: usesStrictDirectDecoder, + skipCurrentContainerValidation: usesDirectDecoding ) ) let newSyntax = CodeBlockItemListSyntax { if usesStrictDirectDecoder { let metadata = strictDecodingMetadataSyntax( - ["": location.additionalAllowedKeys] + ["": directAllowedKeys] ) "let \(directDecoder) = MetaCodableStrictDecoder(decoder: \(location.coder), allowedKeysByPath: \(metadata))" } diff --git a/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift index e38354ae0b..b6f863c8f3 100644 --- a/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/EnumCaseVariable.swift @@ -16,6 +16,8 @@ where CodingLocation == EnumCaseCodingLocation, Generated == EnumCaseGenerated { var decodingAllowedKeys: [String] { get } /// Whether associated values require a keyed decoding container. var usesKeyedDecoding: Bool { get } + /// Whether associated values include a direct decoding value. + var usesDirectDecoding: Bool { get } } extension EnumCaseVariable { @@ -23,6 +25,8 @@ extension EnumCaseVariable { var decodingAllowedKeys: [String] { [] } /// Associated values decode directly by default. var usesKeyedDecoding: Bool { false } + /// Cases without keyed labels have direct decoding values. + var usesDirectDecoding: Bool { false } /// The arguments passed to encoding condition. /// diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift index 675e917c43..e909756abc 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift @@ -30,6 +30,8 @@ where Wrapped: AdjacentlyTaggableSwitcher { } /// Adjacent case values are decoded from the content container. var caseDecodingSharesRootContainer: Bool { false } + /// Adjacent tagging wraps an internally tagged discriminator. + var isInternallyTagged: Bool { base.isInternallyTagged } /// Keys from a nested discriminator container accepted by direct cases. var caseDecodingAdditionalAllowedKeys: [String] { guard @@ -261,7 +263,7 @@ extension AdjacentlyTaggedEnumSwitcher { from location: PropertyCodingLocation ) -> CodeBlockItemListSyntax { switch location { - case .coder(let decoder, _): + case .coder(let decoder, _), .coderWithMetadata(let decoder, _): "let \(self.decoder) = \(decoder)" case .container(let container, let key, _): "let \(self.decoder) = try \(container).superDecoder(forKey: \(key))" @@ -284,7 +286,7 @@ extension AdjacentlyTaggedEnumSwitcher { to location: PropertyCodingLocation ) -> CodeBlockItemListSyntax { switch location { - case .coder(let encoder, _): + case .coder(let encoder, _), .coderWithMetadata(let encoder, _): "let \(self.encoder) = \(encoder)" case .container(let container, let key, _): "let \(self.encoder) = \(container).superEncoder(forKey: \(key))" diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift index 1c8389f333..940f39815a 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift @@ -29,6 +29,8 @@ struct AnyEnumSwitcher: EnumSwitcherVariable { var caseDecodingSharesRootContainer: Bool { base.caseDecodingSharesRootContainer } + /// Whether the wrapped switcher uses an internal discriminator. + var isInternallyTagged: Bool { base.isInternallyTagged } /// Whether the wrapped switcher allows direct decoding cases. var allowsDirectDecoding: Bool { base.allowsDirectDecoding } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift index 5ac1f761c2..0fd61a827b 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift @@ -19,6 +19,8 @@ where var caseDecodingAdditionalAllowedKeys: [String] { get } /// Whether case values are decoded in the same root container. var caseDecodingSharesRootContainer: Bool { get } + /// Whether the switcher uses an internal discriminator. + var isInternallyTagged: Bool { get } /// Whether some cases may decode directly from scalar or unkeyed values. var allowsDirectDecoding: Bool { get } /// Provides node at which case associated variables are registered. @@ -86,6 +88,8 @@ extension EnumSwitcherVariable { var caseDecodingAdditionalAllowedKeys: [String] { [] } /// Case values are nested unless a switcher opts into sharing the root. var caseDecodingSharesRootContainer: Bool { false } + /// Switchers are not internally tagged by default. + var isInternallyTagged: Bool { false } /// Switchers do not mix direct and keyed representations by default. var allowsDirectDecoding: Bool { false } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift index e2fed87d19..92087f6cf1 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift @@ -28,6 +28,8 @@ where } /// Internally tagged case values share the discriminator container. var caseDecodingSharesRootContainer: Bool { true } + /// This switcher uses an internal discriminator. + var isInternallyTagged: Bool { true } /// The identifier variable build action type. /// /// Used to build the identifier data and pass in encoding callback. @@ -410,7 +412,7 @@ extension InternallyTaggedEnumSwitcher { from location: PropertyCodingLocation ) -> CodeBlockItemListSyntax { switch location { - case .coder(let decoder, _): + case .coder(let decoder, _), .coderWithMetadata(let decoder, _): "\(self.decoder) = \(decoder)" case .container(let container, _, _): "\(self.decoder) = \(container)" @@ -433,7 +435,7 @@ extension InternallyTaggedEnumSwitcher { to location: PropertyCodingLocation ) -> CodeBlockItemListSyntax { switch location { - case .coder(let encoder, _): + case .coder(let encoder, _), .coderWithMetadata(let encoder, _): "let \(self.encoder) = \(encoder)" case .container(let container, _, _): "var \(self.encoder) = \(container)" diff --git a/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift b/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift index 7eb39bca53..589433d0b9 100644 --- a/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift @@ -164,6 +164,28 @@ struct BasicPropertyVariable: DefaultPropertyVariable, DeclaredVariable { \(decodePrefix)\(name) = try \(type)\(optionalToken)(from: \(decoder)) """ } + case .coderWithMetadata(let decoder, let passedMethod): + let optionalToken: TokenSyntax = + passedMethod?.trimmedDescription == "decodeIfPresent" ? "?" : "" + var type = type + if let implicitlyUnwrappedType = type.as( + ImplicitlyUnwrappedOptionalTypeSyntax.self + ) { + type = TypeSyntax( + OptionalTypeSyntax( + wrappedType: implicitlyUnwrappedType.wrappedType + ) + ) + } + return CodeBlockItemListSyntax { + if hasOptionalType { + "\(decodePrefix)\(name) = try MetaCodableStrictDecoder.decodeOptional(\(type.wrappedType).self, from: \(decoder))" + } else { + """ + \(decodePrefix)\(name) = try \(type)\(optionalToken)(from: \(decoder)) + """ + } + } case .container(let container, let key, let passedMethod): let (type, defMethod) = codingTypeMethod(forMethod: "decode") let method = passedMethod ?? defMethod @@ -195,7 +217,7 @@ struct BasicPropertyVariable: DefaultPropertyVariable, DeclaredVariable { to location: PropertyCodingLocation ) -> CodeBlockItemListSyntax { switch location { - case .coder(let encoder, _): + case .coder(let encoder, _), .coderWithMetadata(let encoder, _): return CodeBlockItemListSyntax { """ try \(encodePrefix)\(name).encode(to: \(encoder)) diff --git a/Sources/PluginCore/Variables/Property/DefaultValueVariable.swift b/Sources/PluginCore/Variables/Property/DefaultValueVariable.swift index cda8f36617..985886a74c 100644 --- a/Sources/PluginCore/Variables/Property/DefaultValueVariable.swift +++ b/Sources/PluginCore/Variables/Property/DefaultValueVariable.swift @@ -100,6 +100,8 @@ where switch location { case .coder(let decoder, _): .coder(decoder, method: method) + case .coderWithMetadata(let decoder, _): + .coderWithMetadata(decoder, method: method) case .container(let container, let key, _): .container(container, key: key, method: method) } diff --git a/Sources/PluginCore/Variables/Property/HelperCodedVariable.swift b/Sources/PluginCore/Variables/Property/HelperCodedVariable.swift index b6e72e41dc..c5ed1aeed2 100644 --- a/Sources/PluginCore/Variables/Property/HelperCodedVariable.swift +++ b/Sources/PluginCore/Variables/Property/HelperCodedVariable.swift @@ -64,9 +64,9 @@ where Wrapped: DefaultPropertyVariable { /// and used to generate assisted decoding/encoding syntax. var helperExpr: ExprSyntax { switch self { - case let .helper(expr): + case .helper(let expr): return expr - case let .helperAction(action, parameters): + case .helperAction(let action, let parameters): let args = LabeledExprListSyntax { for param in parameters { LabeledExprSyntax(expression: param.asArg) @@ -107,9 +107,9 @@ where Wrapped: DefaultPropertyVariable { /// helper action to create the helper expression syntax. var asArg: ExprSyntax { switch self { - case let .argument(expr): + case .argument(let expr): expr - case let .property(comps): + case .property(let comps): "self\(comps)" } } @@ -169,7 +169,8 @@ where Wrapped: DefaultPropertyVariable { ) -> CodeBlockItemListSyntax { let (_, defMethod) = codingTypeMethod(forMethod: "decode") switch location { - case .coder(let decoder, let passedMethod): + case .coder(let decoder, let passedMethod), + .coderWithMetadata(let decoder, let passedMethod): let method = passedMethod ?? defMethod return CodeBlockItemListSyntax { """ @@ -208,7 +209,8 @@ where Wrapped: DefaultPropertyVariable { ) -> CodeBlockItemListSyntax { let (_, defMethod) = codingTypeMethod(forMethod: "encode") switch location { - case .coder(let encoder, let passedMethod): + case .coder(let encoder, let passedMethod), + .coderWithMetadata(let encoder, let passedMethod): let method = passedMethod ?? defMethod return CodeBlockItemListSyntax { """ @@ -257,7 +259,7 @@ where Wrapped: DefaultPropertyVariable { switch options { case .helper: return base.depends(on: variable) - case let .helperAction(_, parameters): + case .helperAction(_, let parameters): return parameters.firstIndex { parameter in switch parameter { case .property(let components): diff --git a/Sources/PluginCore/Variables/Property/PropertyVariable.swift b/Sources/PluginCore/Variables/Property/PropertyVariable.swift index 5b3b137526..619229efb7 100644 --- a/Sources/PluginCore/Variables/Property/PropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/PropertyVariable.swift @@ -94,6 +94,9 @@ package enum PropertyCodingLocation { /// - coder: The decoder/encoder for decoding/encoding. /// - method: The method to use for decoding/encoding. case coder(_ coder: TokenSyntax, method: ExprSyntax?) + /// A top-level location that preserves strict decoder metadata for direct + /// optional decoding. + case coderWithMetadata(_ coder: TokenSyntax, method: ExprSyntax?) /// Represents decoding/encoding location at a `CodingKey` /// for a container. /// diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift index c869943aa8..a764bceaeb 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift @@ -22,6 +22,8 @@ extension PropertyVariableTreeNode { let codingPath: [String] /// Paths whose generated validation is deferred. let skipUnknownKeyValidationPaths: Set + /// Whether direct decoding must preserve strict decoder metadata. + let preserveDecoderMetadata: Bool /// Whether validation for the current root container is deferred. let skipCurrentContainerValidation: Bool /// The decoding/encoding progress. @@ -45,6 +47,7 @@ extension PropertyVariableTreeNode { additionalAllowedKeysByPath: [String: [String]], codingPath: [String], skipUnknownKeyValidationPaths: Set, + preserveDecoderMetadata: Bool, skipCurrentContainerValidation: Bool ) { self.context = context @@ -56,6 +59,7 @@ extension PropertyVariableTreeNode { self.codingPath = codingPath self.skipUnknownKeyValidationPaths = skipUnknownKeyValidationPaths + self.preserveDecoderMetadata = preserveDecoderMetadata self.skipCurrentContainerValidation = skipCurrentContainerValidation } @@ -97,6 +101,7 @@ extension PropertyVariableTreeNode { additionalAllowedKeys: [String] = [], additionalAllowedKeysByPath: [String: [String]] = [:], skipUnknownKeyValidationPaths: Set = [], + preserveDecoderMetadata: Bool = false, skipCurrentContainerValidation: Bool = false ) -> Self { .init( @@ -108,6 +113,7 @@ extension PropertyVariableTreeNode { codingPath: [], skipUnknownKeyValidationPaths: skipUnknownKeyValidationPaths, + preserveDecoderMetadata: preserveDecoderMetadata, skipCurrentContainerValidation: skipCurrentContainerValidation ) @@ -136,10 +142,26 @@ extension PropertyVariableTreeNode { codingPath: codingPath, skipUnknownKeyValidationPaths: skipUnknownKeyValidationPaths, + preserveDecoderMetadata: preserveDecoderMetadata, skipCurrentContainerValidation: false ) } + /// The decoding/encoding location for an individual variable. + var forVariable: PropertyCodingLocation { + switch context { + case .coder(let coder, keyType: _): + if preserveDecoderMetadata { + return .coderWithMetadata(coder, method: nil) + } + return .coder(coder, method: nil) + case .container(let container, let key): + return .container( + container.name, key: key.expr, method: nil + ) + } + } + /// Provides the syntax for decoding at the provided location. /// /// Provides syntax for remaining pending variables that diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index a7eaa2504b..c52f1e7283 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -50,7 +50,7 @@ extension PropertyVariableTreeNode { in context: some MacroExpansionContext, from location: CodingLocation ) -> Generated { - let varLocation = location.context.forVariable + let varLocation = location.forVariable let decodingSyntax = CodeBlockItemListSyntax { for variable in data?.variables ?? variables where variable.decode ?? true { diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index ce008b71bc..0838493dc6 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -271,18 +271,21 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { let selfType: ExprSyntax = "\(name).self" let code: CodeBlockItemListSyntax if cases.contains(where: { $0.variable.decode ?? true }) { - let hasDirectDecodingCase = - switcher.allowsDirectDecoding - && cases.contains { - !$0.variable.usesKeyedDecoding - } + let hasDirectDecodingCase = cases.contains { + !$0.variable.usesKeyedDecoding + || $0.variable.usesDirectDecoding + || $0.variable.variables.count + > $0.variable.decodingAllowedKeys.count + } let hasDelegatedDirectCase = - switcher.caseDecodingSharesRootContainer - && cases.contains { - !$0.variable.usesKeyedDecoding - } + (switcher.caseDecodingSharesRootContainer + || switcher.isInternallyTagged) + && hasDirectDecodingCase + let deferRootUnknownKeyValidation = + hasDelegatedDirectCase + || (switcher.isInternallyTagged && hasDirectDecodingCase) let shouldPreflightKeyedContainer = - !hasDelegatedDirectCase + !deferRootUnknownKeyValidation && (switcher.usesKeyedContainer || switcher.allowsDirectDecoding && !hasDirectDecodingCase) let shouldConditionallyPreflightKeyedContainer = @@ -311,7 +314,8 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { switcher.caseDecodingAdditionalAllowedKeys, additionalAllowedKeysByPath: [:], skipUnknownKeyValidationPaths: [], - skipRootUnknownKeyValidation: hasDelegatedDirectCase + skipRootUnknownKeyValidation: + deferRootUnknownKeyValidation ) code = CodeBlockItemListSyntax { if location.rejectUnknownKeys { diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 0071c555d3..54053e57fd 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -49,6 +49,53 @@ struct StrictDecodingTests { } } + @Test + func strictInternalEnumsAllowDiscriminatorKeysInOptionalPayloads() + throws + { + let data = Data( + #"{"type":"item","name":"n","count":1}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictOptionalDelegatedInternalCommand.self, + from: data + ) + if case .item(let value) = decoded { + #expect(value?.name == "n") + #expect(value?.count == 1) + } else { + Issue.record("Expected optional delegated item case") + } + } + + @Test + func strictInternalEnumsAllowMixedDirectAndKeyedPayloads() throws { + let data = Data( + #"{"type":"item","name":"n","count":1}"#.utf8 + ) + let permissive = try JSONDecoder().decode( + StrictMixedInternalCommand.self, + from: data + ) + if case .item(let payload, let count) = permissive { + #expect(payload.name == "n") + #expect(count == 1) + } else { + Issue.record("Expected mixed delegated item case") + } + + let strict = try JSONDecoder().decode( + StrictMixedStrictInternalCommand.self, + from: data + ) + if case .item(let payload, let count) = strict { + #expect(payload.name == "n") + #expect(count == 1) + } else { + Issue.record("Expected mixed strict delegated item case") + } + } + @Test func strictNestedAdjacentEnumsAllowDelegatedPayloadKeys() throws { let data = Data( @@ -457,6 +504,30 @@ struct StrictDecodingTests { case item(StrictDecodingTests.StrictRecord) } + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictOptionalDelegatedInternalCommand { + case item(StrictDecodingTests.StrictRecord?) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictMixedInternalCommand { + case item( + StrictDecodingTests.PermissivePayload, + count: Int + ) + } + + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictMixedStrictInternalCommand { + case item( + StrictDecodingTests.StrictRecord, + count: Int + ) + } + @Codable(rejectUnknownKeys: true) @CodedAt("type") @ContentAt("content") From c06a9d6fed14b93302fd6362c8f620d877a780d0 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 17:48:24 -0500 Subject: [PATCH 29/50] Harden remaining delegated enum cases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 6 ++ .../Enum/Case/BasicEnumCaseVariable.swift | 11 ++- .../Enum/Switcher/UnTaggedEnumSwitcher.swift | 11 ++- .../Variables/Type/EnumVariable.swift | 23 +++--- .../StrictDecodingTests.swift | 72 +++++++++++++++++++ 5 files changed, 109 insertions(+), 14 deletions(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index 5811b39fe8..76707876a1 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -45,6 +45,12 @@ public struct MetaCodableStrictDecoder: Decoder { if container.decodeNil() { return nil } + if Value.self == Date.self || Value.self == Data.self, + let strictDecoder = decoder as? MetaCodableStrictDecoder + { + return try strictDecoder.base.singleValueContainer().decode( + Value.self) + } return try Value(from: decoder) } diff --git a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift index 1370d8c44c..3532611d11 100644 --- a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift @@ -67,7 +67,7 @@ struct BasicEnumCaseVariable: EnumCaseVariable { let codingKeys: CodingKeysMap /// Keys recognized by decoding associated values at the current level. var decodingAllowedKeys: [String] { - Array(decodingNode.allowedKeys) + Array(decodingData.allowedKeys) } /// Whether associated values require a keyed decoding container. var usesKeyedDecoding: Bool { @@ -75,7 +75,14 @@ struct BasicEnumCaseVariable: EnumCaseVariable { } /// Whether associated values include an unlabeled direct value. var usesDirectDecoding: Bool { - variables.contains { $0.label == nil } + let hasDecodedValue = variables.contains { $0.decode ?? true } + return hasDecodedValue && !usesKeyedDecoding + || decodingData.variables.contains { $0.decode ?? true } + || variables.contains { variable in + guard variable.decode ?? true else { return false } + guard let label = variable.label else { return true } + return label.text == "_" + } } /// Creates a new enum-case variable from provided data. diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index 98d624d1dd..c7d12a4741 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -167,10 +167,17 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { let `case` = cases.removeFirst() let coder = location.coder + let isDirectCase = + `case`.variable.usesDirectDecoding + || (!`case`.variable.usesKeyedDecoding + && !`case`.variable.variables.isEmpty) let cLocation = EnumCaseCodingLocation( coder: coder, values: [], - rejectUnknownKeys: location.rejectUnknownKeys, - additionalAllowedKeys: [] + rejectUnknownKeys: + location.rejectUnknownKeys + && !isDirectCase, + additionalAllowedKeys: + `case`.variable.decodingAllowedKeys ) let generated = `case`.variable.decoding(in: context, from: cLocation) let nResult = decodingSyntax( diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index 0838493dc6..d3660c0f9f 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -271,19 +271,22 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { let selfType: ExprSyntax = "\(name).self" let code: CodeBlockItemListSyntax if cases.contains(where: { $0.variable.decode ?? true }) { - let hasDirectDecodingCase = cases.contains { - !$0.variable.usesKeyedDecoding - || $0.variable.usesDirectDecoding - || $0.variable.variables.count - > $0.variable.decodingAllowedKeys.count - } + let hasDirectDecodingCase = + cases.contains { $0.variable.usesDirectDecoding } + || (switcher.allowsDirectDecoding + && cases.contains { + !$0.variable.usesKeyedDecoding + }) let hasDelegatedDirectCase = - (switcher.caseDecodingSharesRootContainer - || switcher.isInternallyTagged) - && hasDirectDecodingCase + hasDirectDecodingCase + let hasAssociatedValues = cases.contains { + !$0.variable.variables.isEmpty + } let deferRootUnknownKeyValidation = hasDelegatedDirectCase - || (switcher.isInternallyTagged && hasDirectDecodingCase) + || (switcher.isInternallyTagged + && switcher.caseDecodingSharesRootContainer + && hasAssociatedValues) let shouldPreflightKeyedContainer = !deferRootUnknownKeyValidation && (switcher.usesKeyedContainer diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 54053e57fd..dea1aaf494 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -30,6 +30,65 @@ struct StrictDecodingTests { } } + @Test + func strictInternalEnumsAllowDiscriminatorKeysInWildcardPayloads() throws { + let data = Data( + #"{"type":"item","name":"n","count":1}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictWildcardDelegatedInternalCommand.self, + from: data + ) + if case .item(let value) = decoded { + #expect(value.name == "n") + #expect(value.count == 1) + } else { + Issue.record("Expected wildcard delegated item case") + } + } + + @Test + func strictOptionalPayloadsPreserveDateAndDataStrategies() throws { + enum StrategyKeys: String, CodingKey { + case date + case data + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.container(keyedBy: StrategyKeys.self) + let timestamp = try container.decode( + Double.self, forKey: .date + ) + return Date(timeIntervalSince1970: timestamp) + } + decoder.dataDecodingStrategy = .custom { decoder in + let container = try decoder.container(keyedBy: StrategyKeys.self) + let value = try container.decode(String.self, forKey: .data) + return try #require(Data(base64Encoded: value)) + } + + let date = try decoder.decode( + StrictOptionalStrategyCommand.self, + from: Data(#"{"type":"date","date":123}"#.utf8) + ) + if case .date(let value) = date { + #expect(value?.timeIntervalSince1970 == 123) + } else { + Issue.record("Expected date case") + } + + let data = try decoder.decode( + StrictOptionalStrategyCommand.self, + from: Data(#"{"type":"data","data":"AQI="}"#.utf8) + ) + if case .data(let value) = data { + #expect(value == Data([1, 2])) + } else { + Issue.record("Expected data case") + } + } + @Test func strictInternalEnumsAllowDiscriminatorKeysInStrictDirectPayloads() throws @@ -504,12 +563,25 @@ struct StrictDecodingTests { case item(StrictDecodingTests.StrictRecord) } + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictWildcardDelegatedInternalCommand { + case item(_: StrictDecodingTests.StrictRecord) + } + @Codable(rejectUnknownKeys: true) @CodedAt("type") enum StrictOptionalDelegatedInternalCommand { case item(StrictDecodingTests.StrictRecord?) } + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictOptionalStrategyCommand { + case date(Date?) + case data(Data?) + } + @Codable(rejectUnknownKeys: true) @CodedAt("type") enum StrictMixedInternalCommand { From ec5045bd0e59d8b9da00242845f9491c19de1bf3 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 18:10:29 -0500 Subject: [PATCH 30/50] Finish direct-case capability propagation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../PluginCore/Variables/ComposedVariable.swift | 4 ++++ .../Enum/Case/BasicEnumCaseVariable.swift | 5 ----- Tests/MetaCodableTests/StrictDecodingTests.swift | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Sources/PluginCore/Variables/ComposedVariable.swift b/Sources/PluginCore/Variables/ComposedVariable.swift index b3a4139bf7..9c5ab00fff 100644 --- a/Sources/PluginCore/Variables/ComposedVariable.swift +++ b/Sources/PluginCore/Variables/ComposedVariable.swift @@ -193,6 +193,10 @@ where Self: EnumCaseVariable, Wrapped: EnumCaseVariable { var usesKeyedDecoding: Bool { base.usesKeyedDecoding } + /// Provides direct-decoding capability from the underlying enum case. + var usesDirectDecoding: Bool { + base.usesDirectDecoding + } } extension ComposedVariable where Self: TypeVariable, Wrapped: TypeVariable { diff --git a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift index 3532611d11..cda8034e1d 100644 --- a/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Case/BasicEnumCaseVariable.swift @@ -78,11 +78,6 @@ struct BasicEnumCaseVariable: EnumCaseVariable { let hasDecodedValue = variables.contains { $0.decode ?? true } return hasDecodedValue && !usesKeyedDecoding || decodingData.variables.contains { $0.decode ?? true } - || variables.contains { variable in - guard variable.decode ?? true else { return false } - guard let label = variable.label else { return true } - return label.text == "_" - } } /// Creates a new enum-case variable from provided data. diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index dea1aaf494..31f5efe222 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -30,6 +30,15 @@ struct StrictDecodingTests { } } + @Test + func strictInternalEnumsValidateWildcardKeyedPayloads() throws { + try expectDataCorrupted( + StrictWildcardKeyedInternalCommand.self, + json: #"{"type":"item","name":"n","extra":true}"#, + containing: ["extra"] + ) + } + @Test func strictInternalEnumsAllowDiscriminatorKeysInWildcardPayloads() throws { let data = Data( @@ -569,6 +578,12 @@ struct StrictDecodingTests { case item(_: StrictDecodingTests.StrictRecord) } + @Codable(rejectUnknownKeys: true) + @CodedAt("type") + enum StrictWildcardKeyedInternalCommand { + case item(_ name: String) + } + @Codable(rejectUnknownKeys: true) @CodedAt("type") enum StrictOptionalDelegatedInternalCommand { From e3a7f8e98db9e09d8a7568577416b1fd81fd5df3 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 18:29:46 -0500 Subject: [PATCH 31/50] Scope delegated enum validation by container Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift | 10 ++++++++++ .../Variables/Enum/Switcher/AnyEnumSwitcher.swift | 4 ++++ .../Variables/Enum/Switcher/EnumSwitcherVariable.swift | 4 ++++ .../Enum/Switcher/InternallyTaggedEnumSwitcher.swift | 2 ++ .../Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift | 10 +++------- Sources/PluginCore/Variables/Type/EnumVariable.swift | 1 + 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift index e909756abc..817362c92b 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AdjacentlyTaggedEnumSwitcher.swift @@ -32,6 +32,16 @@ where Wrapped: AdjacentlyTaggableSwitcher { var caseDecodingSharesRootContainer: Bool { false } /// Adjacent tagging wraps an internally tagged discriminator. var isInternallyTagged: Bool { base.isInternallyTagged } + /// Adjacent direct payloads share validation only when content and tag + /// paths are nested in the same container. + var directPayloadSharesValidationContainer: Bool { + base.caseDecodingSharesRootContainer + || (base.discriminatorDecodingPath.count + > contentDecodingPath.count + && base.discriminatorDecodingPath.starts( + with: contentDecodingPath + )) + } /// Keys from a nested discriminator container accepted by direct cases. var caseDecodingAdditionalAllowedKeys: [String] { guard diff --git a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift index 940f39815a..59a66ee320 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/AnyEnumSwitcher.swift @@ -31,6 +31,10 @@ struct AnyEnumSwitcher: EnumSwitcherVariable { } /// Whether the wrapped switcher uses an internal discriminator. var isInternallyTagged: Bool { base.isInternallyTagged } + /// Whether direct payloads share the wrapped validation container. + var directPayloadSharesValidationContainer: Bool { + base.directPayloadSharesValidationContainer + } /// Whether the wrapped switcher allows direct decoding cases. var allowsDirectDecoding: Bool { base.allowsDirectDecoding } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift index 0fd61a827b..3da8dc879c 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/EnumSwitcherVariable.swift @@ -21,6 +21,8 @@ where var caseDecodingSharesRootContainer: Bool { get } /// Whether the switcher uses an internal discriminator. var isInternallyTagged: Bool { get } + /// Whether direct payloads share the container being strictly validated. + var directPayloadSharesValidationContainer: Bool { get } /// Whether some cases may decode directly from scalar or unkeyed values. var allowsDirectDecoding: Bool { get } /// Provides node at which case associated variables are registered. @@ -90,6 +92,8 @@ extension EnumSwitcherVariable { var caseDecodingSharesRootContainer: Bool { false } /// Switchers are not internally tagged by default. var isInternallyTagged: Bool { false } + /// Direct payloads do not share an envelope container by default. + var directPayloadSharesValidationContainer: Bool { false } /// Switchers do not mix direct and keyed representations by default. var allowsDirectDecoding: Bool { false } diff --git a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift index 92087f6cf1..791ffb9891 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/InternallyTaggedEnumSwitcher.swift @@ -30,6 +30,8 @@ where var caseDecodingSharesRootContainer: Bool { true } /// This switcher uses an internal discriminator. var isInternallyTagged: Bool { true } + /// Internal direct payloads share the root validation container. + var directPayloadSharesValidationContainer: Bool { true } /// The identifier variable build action type. /// /// Used to build the identifier data and pass in encoding callback. diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index c7d12a4741..4833a4b93c 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -15,6 +15,8 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { var discriminatorDecodingKeys: [String] { [] } /// Untagged enums may mix direct and keyed cases. var allowsDirectDecoding: Bool { true } + /// Untagged payloads are decoded directly from the validated root. + var directPayloadSharesValidationContainer: Bool { true } /// The node at which variables are registered. /// /// Associated variables for all cases are registered with the path @@ -167,15 +169,9 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { let `case` = cases.removeFirst() let coder = location.coder - let isDirectCase = - `case`.variable.usesDirectDecoding - || (!`case`.variable.usesKeyedDecoding - && !`case`.variable.variables.isEmpty) let cLocation = EnumCaseCodingLocation( coder: coder, values: [], - rejectUnknownKeys: - location.rejectUnknownKeys - && !isDirectCase, + rejectUnknownKeys: location.rejectUnknownKeys, additionalAllowedKeys: `case`.variable.decodingAllowedKeys ) diff --git a/Sources/PluginCore/Variables/Type/EnumVariable.swift b/Sources/PluginCore/Variables/Type/EnumVariable.swift index d3660c0f9f..9f4836c354 100644 --- a/Sources/PluginCore/Variables/Type/EnumVariable.swift +++ b/Sources/PluginCore/Variables/Type/EnumVariable.swift @@ -279,6 +279,7 @@ package struct EnumVariable: TypeVariable, DeclaredVariable { }) let hasDelegatedDirectCase = hasDirectDecodingCase + && switcher.directPayloadSharesValidationContainer let hasAssociatedValues = cases.contains { !$0.variable.variables.isEmpty } From e1adc2c7b70e14f645c10ffc78313c4110ae728c Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 18:45:10 -0500 Subject: [PATCH 32/50] Refresh Copilot review after final fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> From 8df77add13fd19d192b5cb68bf885c52ed9972fa Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 18:54:45 -0500 Subject: [PATCH 33/50] Isolate untagged decoding attempts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 71 +++++++++++++++++++ .../Enum/Switcher/UnTaggedEnumSwitcher.swift | 18 ++++- .../StrictDecodingTests.swift | 22 ++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index 76707876a1..415d0f3086 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -54,6 +54,59 @@ public struct MetaCodableStrictDecoder: Decoder { return try Value(from: decoder) } + /// Begins an isolated decoding attempt for backtracking decoders. + public static func beginAttempt(for decoder: any Decoder) -> Attempt { + guard let strictDecoder = decoder as? MetaCodableStrictDecoder + else { + return Attempt(decoder: decoder) + } + + let isolatedState = strictDecoder.state.copy() + let isolatedDecoder = MetaCodableStrictDecoder( + base: strictDecoder.base, + state: isolatedState + ) + return Attempt( + decoder: isolatedDecoder, + commit: { + strictDecoder.state.replace(with: isolatedState) + } + ) + } + + /// An isolated decoding attempt whose state is committed only on success. + public final class Attempt { + /// The decoder used by this attempt. + public let decoder: any Decoder + private let commitAction: (() -> Void)? + private var finished = false + + fileprivate init(decoder: any Decoder) { + self.decoder = decoder + self.commitAction = nil + } + + fileprivate init( + decoder: any Decoder, + commit: @escaping () -> Void + ) { + self.decoder = decoder + self.commitAction = commit + } + + /// Commits observed metadata and consumed keys to the parent decoder. + public func commit() { + guard !finished else { return } + finished = true + commitAction?() + } + + /// Discards all metadata and consumed keys observed by the attempt. + public func rollback() { + finished = true + } + } + fileprivate init(base: any Decoder, state: State) { self.base = base self.state = state @@ -144,6 +197,24 @@ public struct MetaCodableStrictDecoder: Decoder { } } + func copy() -> State { + let copy = State( + allowedKeysByPath: allowedKeysByPath.mapValues(Array.init), + rootCodingPath: rootCodingPath + ) + copy.observedKeysByPath = observedKeysByPath + copy.observedContainerPaths = observedContainerPaths + copy.terminalDecoderPaths = terminalDecoderPaths + return copy + } + + func replace(with other: State) { + allowedKeysByPath = other.allowedKeysByPath + observedKeysByPath = other.observedKeysByPath + observedContainerPaths = other.observedContainerPaths + terminalDecoderPaths = other.terminalDecoderPaths + } + fileprivate func path(for codingPath: [any CodingKey]) -> String { let components = codingPath.map(\.stringValue) let relative = diff --git a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift index 4833a4b93c..7beb122bdd 100644 --- a/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift +++ b/Sources/PluginCore/Variables/Enum/Switcher/UnTaggedEnumSwitcher.swift @@ -168,7 +168,10 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { } let `case` = cases.removeFirst() - let coder = location.coder + let attempt = context.makeUniqueName("strictAttempt") + let attemptDecoder = context.makeUniqueName("strictAttemptDecoder") + let coder = + location.rejectUnknownKeys ? attemptDecoder : location.coder let cLocation = EnumCaseCodingLocation( coder: coder, values: [], rejectUnknownKeys: location.rejectUnknownKeys, @@ -188,10 +191,16 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { ) { "\(strictError) = decodingError" } + if location.rejectUnknownKeys { + "\(attempt).rollback()" + } nResult.syntax } } else { CatchClauseSyntax { + if location.rejectUnknownKeys { + "\(attempt).rollback()" + } nResult.syntax } } @@ -206,6 +215,9 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { generated.code.codingSyntax generated.code.conditionalSyntax location.codeExpr(eCase.name, eCase.variables) + if location.rejectUnknownKeys { + "\(attempt).commit()" + } "return" } @@ -221,6 +233,10 @@ struct UnTaggedEnumSwitcher: EnumSwitcherVariable { if !location.rejectUnknownKeys { generated.code.containerSyntax } + if location.rejectUnknownKeys { + "let \(attempt) = MetaCodableStrictDecoder.beginAttempt(for: \(location.coder))" + "let \(attemptDecoder) = \(attempt).decoder" + } if tVisitor.throws { DoStmtSyntax(catchClauses: catchClauses) { doBlock diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 31f5efe222..4e5d7306be 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -644,6 +644,13 @@ struct StrictDecodingTests { case number(value: Int) } + @Codable(rejectUnknownKeys: true) + @UnTagged + enum StrictTransactionalUntaggedCommand { + case number(value: Int) + case record(StrictDecodingTests.PermissivePayload, count: Int) + } + @Codable(rejectUnknownKeys: true) @CodedAt("meta", "kind") @ContentAt("meta") @@ -1251,6 +1258,21 @@ struct StrictDecodingTests { } } + @Test + func strictUntaggedAttemptsRollbackFailedMetadata() throws { + let data = Data(#"{"name":"n","count":1}"#.utf8) + let decoded = try JSONDecoder().decode( + StrictTransactionalUntaggedCommand.self, + from: data + ) + if case .record(let payload, let count) = decoded { + #expect(payload.name == "n") + #expect(count == 1) + } else { + Issue.record("Expected transactional record case") + } + } + @Test func strictRawValueEnumsRemainScalar() throws { let data = Data(#""ready""#.utf8) From 15a9172cb1e552bbe58ee1fb5505f0d275b956fe Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 18:57:08 -0500 Subject: [PATCH 34/50] Refresh Copilot review after transactional fix Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> From 5062bd3c0609ba224107e54f56c6a5e7805996f6 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 19:28:16 -0500 Subject: [PATCH 35/50] Validate mixed direct and keyed records Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...pertyVariableTreeNode+CodingLocation.swift | 21 ++++ .../Tree/PropertyVariableTreeNode.swift | 102 +++++++++++++----- .../StrictDecodingTests.swift | 6 ++ 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift index a764bceaeb..85ad546c4a 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift @@ -147,6 +147,27 @@ extension PropertyVariableTreeNode { ) } + /// Rebinds the current decoder while preserving strict metadata. + func withCoder( + _ coder: TokenSyntax, + preserveDecoderMetadata: Bool + ) -> Self { + guard case .coder(_, let keyType) = context else { return self } + return .init( + context: .coder(coder, keyType: keyType), progress: progress, + rejectUnknownKeys: rejectUnknownKeys, + deferUnknownKeyValidation: deferUnknownKeyValidation, + additionalAllowedKeys: additionalAllowedKeys, + additionalAllowedKeysByPath: additionalAllowedKeysByPath, + codingPath: codingPath, + skipUnknownKeyValidationPaths: + skipUnknownKeyValidationPaths, + preserveDecoderMetadata: preserveDecoderMetadata, + skipCurrentContainerValidation: + skipCurrentContainerValidation + ) + } + /// The decoding/encoding location for an individual variable. var forVariable: PropertyCodingLocation { switch context { diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index c52f1e7283..0f02725a89 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -50,19 +50,10 @@ extension PropertyVariableTreeNode { in context: some MacroExpansionContext, from location: CodingLocation ) -> Generated { - let varLocation = location.forVariable - let decodingSyntax = CodeBlockItemListSyntax { - for variable in data?.variables ?? variables - where variable.decode ?? true { - if variable.dependenciesCount == 0 { - variable.decoding(in: context, from: varLocation) - let _ = location.coded(variable) - } else { - let _ = location.pending(variable) - } - } + let directVariables = data?.variables ?? variables + let hasDirectDecodableVariables = directVariables.contains { + $0.decode ?? true } - let childrenDecodable = data?.children.contains { _, node in node.linkedVariables.contains { $0.decode ?? true } @@ -70,35 +61,82 @@ extension PropertyVariableTreeNode { ?? children.contains { _, node in node.linkedVariables.contains { $0.decode ?? true } } - let directVariables = data?.variables ?? variables - let hasDirectDecodableVariables = directVariables.contains { - $0.decode ?? true - } + let currentPath = strictDecodingPathKey(location.codingPath) + let skipsValidation = + location.skipCurrentContainerValidation + || location.skipUnknownKeyValidationPaths.contains(currentPath) + let hasMixedStrictValidation = + location.rejectUnknownKeys + && !location.deferUnknownKeyValidation + && !skipsValidation + && hasDirectDecodableVariables + && childrenDecodable + let directLocation: CodingLocation = + if hasMixedStrictValidation { + location.withCoder( + context.makeUniqueName("strictDecoder"), + preserveDecoderMetadata: true + ) + } else { + location + } + let strictDecoder: TokenSyntax? = + if hasMixedStrictValidation, + case .coder(let directDecoder, _) = directLocation.context + { + directDecoder + } else { + nil + } let strictValidation = location.rejectUnknownKeys && !location.deferUnknownKeyValidation - && !location.skipCurrentContainerValidation - && !location.skipUnknownKeyValidationPaths.contains( - strictDecodingPathKey(location.codingPath) - ) + && !skipsValidation && !hasDirectDecodableVariables + let currentAllowedKeys = + Array(data?.allowedKeys ?? self.allowedKeys) + + location.additionalAllowedKeys let allowedKeys: [String]? = - if strictValidation { - Array(data?.allowedKeys ?? self.allowedKeys) - + location.additionalAllowedKeys - } else { - nil + strictValidation ? currentAllowedKeys : nil + let deferredAllowedKeys: [String]? = + hasMixedStrictValidation ? currentAllowedKeys : nil + let wrapperSyntax: CodeBlockItemListSyntax = { + guard + let strictDecoder, + case .coder(let decoder, _) = location.context + else { return [] } + let metadata = strictDecodingMetadataSyntax( + ["": deferredAllowedKeys ?? []] + ) + return + "let \(strictDecoder) = MetaCodableStrictDecoder(decoder: \(decoder), allowedKeysByPath: \(metadata))" + }() + let decodingSyntax = CodeBlockItemListSyntax { + wrapperSyntax + for variable in directVariables + where variable.decode ?? true { + if variable.dependenciesCount == 0 { + variable.decoding( + in: context, from: directLocation.forVariable + ) + let _ = location.coded(variable) + } else { + let _ = location.pending(variable) + } } + } guard !(data?.children.isEmpty ?? children.isEmpty), childrenDecodable else { if strictValidation, !hasDirectDecodableVariables, case .coder(let decoder, _) = location.context { return .init( - containerSyntax: strictDecodingValidationSyntax( - decoder: "\(decoder)" as ExprSyntax, - allowedKeys: allowedKeys - ), + containerSyntax: CodeBlockItemListSyntax { + strictDecodingValidationSyntax( + decoder: "\(decoder)" as ExprSyntax, + allowedKeys: allowedKeys + ) + }, codingSyntax: decodingSyntax, conditionalSyntax: "" ) @@ -159,6 +197,12 @@ extension PropertyVariableTreeNode { conditionalSyntax: CodeBlockItemListSyntax { generated.codingSyntax generated.conditionalSyntax + if let strictDecoder, let deferredAllowedKeys { + let metadata = strictDecodingMetadataSyntax( + ["": deferredAllowedKeys] + ) + "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" + } } ) } diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 4e5d7306be..24676cd541 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -766,6 +766,12 @@ struct StrictDecodingTests { ) #expect(decoded.payload.name == "n") #expect(decoded.count == 1) + + try expectDataCorrupted( + StrictMixedDirectRecord.self, + json: #"{"name":"n","count":1,"extra":true}"#, + containing: ["extra"] + ) } @Test From 387a73c1d5010010e57c806c7b060ed8521eb0f7 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 19:30:26 -0500 Subject: [PATCH 36/50] Refresh Copilot review after final shepherd fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> From 60073931c832a34890316ebf6bc35ec455bdb067 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 19:42:42 -0500 Subject: [PATCH 37/50] Defer mixed validation until pending decoding Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...PropertyVariableTreeNode+CodingLocation.swift | 11 +++++++++++ .../Property/Tree/PropertyVariableTreeNode.swift | 16 +++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift index 85ad546c4a..64595ec05b 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift @@ -81,6 +81,11 @@ extension PropertyVariableTreeNode { progress.pending.append((context, variable)) } + /// Defers validation until all pending variables are decoded. + func deferValidation(_ syntax: CodeBlockItemListSyntax) { + progress.deferredValidations.append(syntax) + } + /// Creates a new location with provided context. /// /// Creates new location with the provided context as top-level context. @@ -238,6 +243,9 @@ extension PropertyVariableTreeNode { syntax } } + for validation in progress.deferredValidations { + validation + } } } @@ -271,6 +279,9 @@ extension PropertyVariableTreeNode { /// The decoding syntax for these variables can be created /// from the location instance. fileprivate(set) var pending: [Pending] = [] + /// Validations emitted after all pending variables decode. + fileprivate(set) var deferredValidations: + [CodeBlockItemListSyntax] = [] } /// Represents the context for decoding/encoding that the node needs diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index 0f02725a89..0dd0d20bcd 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -111,6 +111,14 @@ extension PropertyVariableTreeNode { return "let \(strictDecoder) = MetaCodableStrictDecoder(decoder: \(decoder), allowedKeysByPath: \(metadata))" }() + if let strictDecoder, let deferredAllowedKeys { + let metadata = strictDecodingMetadataSyntax( + ["": deferredAllowedKeys] + ) + directLocation.deferValidation( + "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" + ) + } let decodingSyntax = CodeBlockItemListSyntax { wrapperSyntax for variable in directVariables @@ -121,7 +129,7 @@ extension PropertyVariableTreeNode { ) let _ = location.coded(variable) } else { - let _ = location.pending(variable) + let _ = directLocation.pending(variable) } } } @@ -197,12 +205,6 @@ extension PropertyVariableTreeNode { conditionalSyntax: CodeBlockItemListSyntax { generated.codingSyntax generated.conditionalSyntax - if let strictDecoder, let deferredAllowedKeys { - let metadata = strictDecodingMetadataSyntax( - ["": deferredAllowedKeys] - ) - "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" - } } ) } From d7f402520b0ce448307c6ed8f0072777c9152515 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 19:57:07 -0500 Subject: [PATCH 38/50] Refresh Copilot review after resolving final threads Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> From 2414fe21d58a1a63b3add584c7a7f94029237171 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 20:10:57 -0500 Subject: [PATCH 39/50] Preserve metadata for inherited optional payloads Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Property/BasicPropertyVariable.swift | 2 +- .../Variables/Type/MemberGroup.swift | 4 ++- .../StrictDecodingTests.swift | 33 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift b/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift index 589433d0b9..385b0b833c 100644 --- a/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift +++ b/Sources/PluginCore/Variables/Property/BasicPropertyVariable.swift @@ -178,7 +178,7 @@ struct BasicPropertyVariable: DefaultPropertyVariable, DeclaredVariable { ) } return CodeBlockItemListSyntax { - if hasOptionalType { + if hasOptionalType || optionalToken == "?" { "\(decodePrefix)\(name) = try MetaCodableStrictDecoder.decodeOptional(\(type.wrappedType).self, from: \(decoder))" } else { """ diff --git a/Sources/PluginCore/Variables/Type/MemberGroup.swift b/Sources/PluginCore/Variables/Type/MemberGroup.swift index 6ffd867d4f..5fdac2a643 100644 --- a/Sources/PluginCore/Variables/Type/MemberGroup.swift +++ b/Sources/PluginCore/Variables/Type/MemberGroup.swift @@ -98,7 +98,9 @@ where location.method.arg, keyType: codingKeys.type, rejectUnknownKeys: location.rejectUnknownKeys, deferUnknownKeyValidation: - location.deferUnknownKeyValidation + location.deferUnknownKeyValidation, + preserveDecoderMetadata: + location.rejectUnknownKeys ) decodingNode.decoding(in: context, from: nLocation).combined() nLocation.decoding(in: context) diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 24676cd541..ca97efb8e5 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -346,6 +346,25 @@ struct StrictDecodingTests { } } + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictOptionalDirectBase { + @CodedAt + var payload: StrictRecord? = nil + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictOptionalDirectChild: StrictOptionalDirectBase { + var child: String = "" + + override init() { + super.init() + } + } + @Codable(rejectUnknownKeys: true) @Inherits(decodable: false, encodable: false) class StrictOptionalScalarBase { @@ -792,6 +811,20 @@ struct StrictDecodingTests { #expect(decoded.value == nil) } + @Test + func strictInheritedOptionalDirectPayloadPreservesMetadata() throws { + let data = Data( + #"{"name":"n","count":1,"child":"c"}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictOptionalDirectChild.self, + from: data + ) + #expect(decoded.payload?.name == "n") + #expect(decoded.payload?.count == 1) + #expect(decoded.child == "c") + } + @Test func strictRecordDecodesKnownKeys() throws { let data = Data( From 8955150ec1b9cd43a9aa95afe668ce993142074d Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 20:25:43 -0500 Subject: [PATCH 40/50] Refresh Copilot review after metadata fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> From 9830a351bfc544ab8baaffe72cf7144cea261e2f Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 20:29:54 -0500 Subject: [PATCH 41/50] Preserve specialized optional decoding strategies Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 8 ++++---- .../StrictDecodingTests.swift | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index 415d0f3086..6f4c0b7f8e 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -45,11 +45,11 @@ public struct MetaCodableStrictDecoder: Decoder { if container.decodeNil() { return nil } - if Value.self == Date.self || Value.self == Data.self, - let strictDecoder = decoder as? MetaCodableStrictDecoder + if Value.self == Date.self + || Value.self == Data.self + || Value.self == Decimal.self { - return try strictDecoder.base.singleValueContainer().decode( - Value.self) + return try container.decode(Value.self) } return try Value(from: decoder) } diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index ca97efb8e5..bbff379013 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -670,6 +670,12 @@ struct StrictDecodingTests { case record(StrictDecodingTests.PermissivePayload, count: Int) } + @Codable(rejectUnknownKeys: true) + @UnTagged + enum StrictOptionalDecimalCommand { + case decimal(Decimal?) + } + @Codable(rejectUnknownKeys: true) @CodedAt("meta", "kind") @ContentAt("meta") @@ -1312,6 +1318,19 @@ struct StrictDecodingTests { } } + @Test + func strictOptionalDecimalPayloadUsesSingleValueDecoding() throws { + let decoded = try JSONDecoder().decode( + StrictOptionalDecimalCommand.self, + from: Data("123.45".utf8) + ) + if case .decimal(let value) = decoded { + #expect(value == Decimal(string: "123.45")) + } else { + Issue.record("Expected decimal case") + } + } + @Test func strictRawValueEnumsRemainScalar() throws { let data = Data(#""ready""#.utf8) From 34bba8a626e9ad17d300cb7928a516edaad6f4ba Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 20:43:44 -0500 Subject: [PATCH 42/50] Refresh Copilot review after specialized strategy fix Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> From f631ef755321210009350690a3624dcb90b8e9bd Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 20:57:13 -0500 Subject: [PATCH 43/50] Refresh Copilot review after resolving metadata threads Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> From ccb51e4b3045d8bbf302aaaa327fc93ed817aadd Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 21:21:34 -0500 Subject: [PATCH 44/50] Fix nested strict mixed decoding Ensure nested direct and keyed properties share strict decoder metadata without container redeclarations. Add regression coverage for delegated nested payload keys.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Property/Data/DecodingFallback.swift | 19 +++++++++- ...pertyVariableTreeNode+CodingLocation.swift | 25 +++++++++++-- .../Tree/PropertyVariableTreeNode.swift | 37 +++++++++++++++---- .../StrictDecodingTests.swift | 27 ++++++++++++++ 4 files changed, 96 insertions(+), 12 deletions(-) diff --git a/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift b/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift index 2b95400a37..bb25e1f8e5 100644 --- a/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift +++ b/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift @@ -43,6 +43,8 @@ package enum DecodingFallback { /// some variables as direct children. /// - allowedKeys: The keys recognized by the current container, or /// `nil` when strict validation is disabled. + /// - prefixSyntax: Syntax emitted before the represented container. + /// - containerName: An explicit represented container name. /// - decoding: The nested container decoding /// code block generator. /// @@ -52,6 +54,8 @@ package enum DecodingFallback { nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, allowedKeys: [String]?, + prefixSyntax: CodeBlockItemListSyntax = [], + containerName: TokenSyntax? = nil, nestedDecoding decoding: (Container) -> Generated ) -> Generated { switch context { @@ -61,6 +65,8 @@ package enum DecodingFallback { nestedContainer: nestedContainer, nestedContainerHasVariables: nestedContainerHasVariables, allowedKeys: allowedKeys, + prefixSyntax: prefixSyntax, + containerName: containerName, nestedDecoding: decoding ) case .container(let container, let key): @@ -69,6 +75,7 @@ package enum DecodingFallback { nestedContainer: nestedContainer, nestedContainerHasVariables: nestedContainerHasVariables, allowedKeys: allowedKeys, + prefixSyntax: prefixSyntax, nestedDecoding: decoding ) } @@ -85,6 +92,8 @@ package enum DecodingFallback { /// some variables as direct children. /// - allowedKeys: The keys recognized by the current container, or /// `nil` when strict validation is disabled. + /// - prefixSyntax: Syntax emitted before the represented container. + /// - containerName: An explicit represented container name. /// - decoding: The nested container decoding /// code block generator. /// @@ -95,6 +104,8 @@ package enum DecodingFallback { nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, allowedKeys: [String]?, + prefixSyntax: CodeBlockItemListSyntax, + containerName: TokenSyntax?, nestedDecoding decoding: (Container) -> Generated ) -> Generated { let isOptional: Bool @@ -108,7 +119,7 @@ package enum DecodingFallback { fallbacks = [] } - let cToken = nestedContainer ?? "container" + let cToken = containerName ?? nestedContainer ?? "container" let container = Container(name: cToken, isOptional: isOptional) let generated = decoding(container) let validation = @@ -121,6 +132,7 @@ package enum DecodingFallback { CodeBlockItemListSyntax {} } let containerSyntax = CodeBlockItemListSyntax { + prefixSyntax if nestedContainer == nil { let `try`: TokenSyntax = isOptional ? "try?" : "try" """ @@ -177,6 +189,7 @@ package enum DecodingFallback { /// some variables as direct children. /// - allowedKeys: The keys recognized by the current container, or /// `nil` when strict validation is disabled. + /// - prefixSyntax: Syntax emitted before the represented container. /// - decoding: The nested container decoding /// code block generator. /// @@ -187,6 +200,7 @@ package enum DecodingFallback { nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, allowedKeys: [String]?, + prefixSyntax: CodeBlockItemListSyntax, nestedDecoding decoding: (Container) -> Generated ) -> Generated { let nContainer = nestedContainer ?? "\(key.raw)_\(container.name)" @@ -217,6 +231,7 @@ package enum DecodingFallback { """ validation } + prefixSyntax generated.containerSyntax } conditionalSyntax = generated.conditionalSyntax @@ -250,6 +265,7 @@ package enum DecodingFallback { let \(nContainer) = ((try? \(container.syntax).decodeNil(forKey: \(key.expr))) == false) ? try \(container.syntax).nestedContainer(keyedBy: \(key.type), forKey: \(key.expr)) : nil """ } + prefixSyntax generated.containerSyntax } conditionalSyntax = CodeBlockItemListSyntax { @@ -307,6 +323,7 @@ package enum DecodingFallback { "\(containerMissing) = true" } } + prefixSyntax generated.containerSyntax } conditionalSyntax = CodeBlockItemListSyntax { diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift index 64595ec05b..2dd01701f2 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift @@ -10,6 +10,7 @@ extension PropertyVariableTreeNode { struct CodingLocation: Variable { /// The context representing the nesting level. let context: Context + let keyType: ExprSyntax /// Whether generated decoding rejects unsupported keys. let rejectUnknownKeys: Bool /// Whether strict validation is emitted after superclass decoding. @@ -37,12 +38,21 @@ extension PropertyVariableTreeNode { /// - Parameters: /// - context: The context representing the nesting level. /// - progress: The decoding/encoding progress. + /// - keyType: The `CodingKey` type for the current decoder. /// - rejectUnknownKeys: Whether generated decoding rejects /// unsupported keys. /// - deferUnknownKeyValidation: Whether strict validation is /// emitted after superclass decoding. + /// - additionalAllowedKeys: Keys allowed in the current container. + /// - additionalAllowedKeysByPath: Keys allowed at nested paths. + /// - codingPath: The normalized coding path for this location. + /// - skipUnknownKeyValidationPaths: Paths exempt from validation. + /// - preserveDecoderMetadata: Whether decoder metadata is preserved. + /// - skipCurrentContainerValidation: Whether the current container + /// is exempt from validation. private init( - context: Context, progress: Progress, rejectUnknownKeys: Bool, + context: Context, progress: Progress, keyType: ExprSyntax, + rejectUnknownKeys: Bool, deferUnknownKeyValidation: Bool, additionalAllowedKeys: [String], additionalAllowedKeysByPath: [String: [String]], codingPath: [String], @@ -51,6 +61,7 @@ extension PropertyVariableTreeNode { skipCurrentContainerValidation: Bool ) { self.context = context + self.keyType = keyType self.progress = progress self.rejectUnknownKeys = rejectUnknownKeys self.deferUnknownKeyValidation = deferUnknownKeyValidation @@ -97,6 +108,12 @@ extension PropertyVariableTreeNode { /// unsupported keys. /// - deferUnknownKeyValidation: Whether strict validation is /// emitted after superclass decoding. + /// - additionalAllowedKeys: Keys allowed in the current container. + /// - additionalAllowedKeysByPath: Keys allowed at nested paths. + /// - skipUnknownKeyValidationPaths: Paths exempt from validation. + /// - preserveDecoderMetadata: Whether decoder metadata is preserved. + /// - skipCurrentContainerValidation: Whether the current container + /// is exempt from validation. /// /// - Returns: The new location updated with provided context. static func withCoder( @@ -111,6 +128,7 @@ extension PropertyVariableTreeNode { ) -> Self { .init( context: .coder(coder, keyType: keyType), progress: .init(), + keyType: keyType, rejectUnknownKeys: rejectUnknownKeys, deferUnknownKeyValidation: deferUnknownKeyValidation, additionalAllowedKeys: additionalAllowedKeys, @@ -137,6 +155,7 @@ extension PropertyVariableTreeNode { let codingPath = codingPath + [key.description] return .init( context: .container(container, key: key), progress: progress, + keyType: keyType, rejectUnknownKeys: rejectUnknownKeys, deferUnknownKeyValidation: deferUnknownKeyValidation, additionalAllowedKeys: @@ -157,9 +176,9 @@ extension PropertyVariableTreeNode { _ coder: TokenSyntax, preserveDecoderMetadata: Bool ) -> Self { - guard case .coder(_, let keyType) = context else { return self } - return .init( + .init( context: .coder(coder, keyType: keyType), progress: progress, + keyType: keyType, rejectUnknownKeys: rejectUnknownKeys, deferUnknownKeyValidation: deferUnknownKeyValidation, additionalAllowedKeys: additionalAllowedKeys, diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index 0dd0d20bcd..b95f34a1c9 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -80,6 +80,12 @@ extension PropertyVariableTreeNode { } else { location } + let nestedMixedValidation: Bool = + hasMixedStrictValidation + && { + if case .container = location.context { return true } + return false + }() let strictDecoder: TokenSyntax? = if hasMixedStrictValidation, case .coder(let directDecoder, _) = directLocation.context @@ -101,10 +107,15 @@ extension PropertyVariableTreeNode { let deferredAllowedKeys: [String]? = hasMixedStrictValidation ? currentAllowedKeys : nil let wrapperSyntax: CodeBlockItemListSyntax = { - guard - let strictDecoder, - case .coder(let decoder, _) = location.context - else { return [] } + guard let strictDecoder else { return [] } + let decoder: ExprSyntax + switch location.context { + case .coder(let decoderExpression, _): + decoder = "\(decoderExpression)" + case .container(let container, let key): + decoder = + "try \(container.name).superDecoder(forKey: \(key.expr))" + } let metadata = strictDecodingMetadataSyntax( ["": deferredAllowedKeys ?? []] ) @@ -120,7 +131,9 @@ extension PropertyVariableTreeNode { ) } let decodingSyntax = CodeBlockItemListSyntax { - wrapperSyntax + if !nestedMixedValidation { + wrapperSyntax + } for variable in directVariables where variable.decode ?? true { if variable.dependenciesCount == 0 { @@ -172,7 +185,9 @@ extension PropertyVariableTreeNode { .reduce(.ifMissing([], ifError: []), +) .represented( - context: location.context, + context: + nestedMixedValidation + ? directLocation.context : location.context, nestedContainer: location.rejectUnknownKeys && !location.deferUnknownKeyValidation @@ -182,14 +197,20 @@ extension PropertyVariableTreeNode { || !self.children.lazy .flatMap(\.value.variables) .filter { $0.decode ?? true }.isEmpty, - allowedKeys: allowedKeys + allowedKeys: allowedKeys, + prefixSyntax: nestedMixedValidation ? wrapperSyntax : [], + containerName: + nestedMixedValidation + ? context.makeUniqueName("strictContainer") : nil ) { container in self.decodingContainer = container.name let generated = decodableChildren.map { cKey, node in node.decoding( with: data?.children[cKey], in: context, - from: location.withContainer(container, key: cKey) + from: (nestedMixedValidation + ? directLocation : location).withContainer( + container, key: cKey) ) }.reduce( .init( diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index bbff379013..e81abe2a9b 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -211,6 +211,14 @@ struct StrictDecodingTests { let count: Int } + @Codable(rejectUnknownKeys: true) + struct StrictMixedNestedDirectRecord { + @CodedAt("payload") + let payload: StrictDecodingTests.PermissivePayload + @CodedIn("payload") + let count: Int + } + @ConformDecodable(rejectUnknownKeys: true) struct StrictDecodableRecord { let value: String @@ -799,6 +807,25 @@ struct StrictDecodingTests { ) } + @Test + func strictMixedNestedDirectAndKeyedRecordsAllowDelegatedKeys() throws { + let data = Data( + #"{"payload":{"name":"n","count":1}}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictMixedNestedDirectRecord.self, + from: data + ) + #expect(decoded.payload.name == "n") + #expect(decoded.count == 1) + + try expectDataCorrupted( + StrictMixedNestedDirectRecord.self, + json: #"{"payload":{"name":"n","count":1,"extra":true}}"#, + containing: ["extra"] + ) + } + @Test func strictScalarClassHierarchiesRemainScalar() throws { let data = Data(#""scalar""#.utf8) From e8adff6254b4ddb426a3ff8590b795d11f4bd675 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 21:32:45 -0500 Subject: [PATCH 45/50] Preserve optional decoder metadata Keep Foundation dictionary strategies intact for optional strict payloads and preserve strict metadata when permissive class superclasses decode under strict subclasses.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Codable/RejectUnknownKeys.swift | 9 ++- .../Variables/Type/MemberGroup.swift | 5 ++ .../StrictDecodingTests.swift | 55 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift index 6f4c0b7f8e..718ca124df 100644 --- a/Sources/MetaCodable/Codable/RejectUnknownKeys.swift +++ b/Sources/MetaCodable/Codable/RejectUnknownKeys.swift @@ -1,5 +1,9 @@ import Foundation +private protocol MetaCodableDictionary {} + +extension Dictionary: MetaCodableDictionary {} + /// A decoder wrapper that carries inherited strict-decoding key metadata. public struct MetaCodableStrictDecoder: Decoder { private let base: any Decoder @@ -45,9 +49,12 @@ public struct MetaCodableStrictDecoder: Decoder { if container.decodeNil() { return nil } - if Value.self == Date.self + let isStrictDecoder = decoder is MetaCodableStrictDecoder + if !isStrictDecoder + || Value.self == Date.self || Value.self == Data.self || Value.self == Decimal.self + || Value.self is any MetaCodableDictionary.Type { return try container.decode(Value.self) } diff --git a/Sources/PluginCore/Variables/Type/MemberGroup.swift b/Sources/PluginCore/Variables/Type/MemberGroup.swift index 5fdac2a643..c8a4311750 100644 --- a/Sources/PluginCore/Variables/Type/MemberGroup.swift +++ b/Sources/PluginCore/Variables/Type/MemberGroup.swift @@ -24,6 +24,9 @@ where /// The `CodingKeys` map containing keys /// and generated case names. let codingKeys: CodingKeysMap + /// Whether direct optional decoding may receive a strict superclass + /// decoder. + let preservesDecoderMetadata: Bool /// Creates a new member group from provided data. /// @@ -76,6 +79,7 @@ where self.decodingNode = decodingNode self.encodingNode = encodingNode self.codingKeys = codingKeys + self.preservesDecoderMetadata = Decl.self == ClassDeclSyntax.self } /// Provides the syntax for decoding at the provided location. @@ -101,6 +105,7 @@ where location.deferUnknownKeyValidation, preserveDecoderMetadata: location.rejectUnknownKeys + || preservesDecoderMetadata ) decodingNode.decoding(in: context, from: nLocation).combined() nLocation.decoding(in: context) diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index e81abe2a9b..08a17c3032 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -219,6 +219,12 @@ struct StrictDecodingTests { let count: Int } + @Codable(rejectUnknownKeys: true) + struct StrictOptionalDictionaryRecord { + @CodedAt + let payload: [String: Int]? + } + @ConformDecodable(rejectUnknownKeys: true) struct StrictDecodableRecord { let value: String @@ -373,6 +379,27 @@ struct StrictDecodingTests { } } + @Codable + @Inherits(decodable: false, encodable: false) + class PermissiveOptionalDirectBase { + @CodedAt + var payload: StrictRecord? = nil + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictOptionalDirectChildOfPermissive: + PermissiveOptionalDirectBase + { + var child: String = "" + + override init() { + super.init() + } + } + @Codable(rejectUnknownKeys: true) @Inherits(decodable: false, encodable: false) class StrictOptionalScalarBase { @@ -858,6 +885,34 @@ struct StrictDecodingTests { #expect(decoded.child == "c") } + @Test + func + strictInheritedOptionalDirectPayloadFromPermissiveBasePreservesMetadata() + throws + { + let data = Data( + #"{"name":"n","count":1,"child":"c"}"#.utf8 + ) + let decoded = try JSONDecoder().decode( + StrictOptionalDirectChildOfPermissive.self, + from: data + ) + #expect(decoded.payload?.name == "n") + #expect(decoded.payload?.count == 1) + #expect(decoded.child == "c") + } + + @Test + func strictOptionalDictionaryPayloadPreservesKeyDecodingStrategy() throws { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let decoded = try decoder.decode( + StrictOptionalDictionaryRecord.self, + from: Data(#"{"some_key":1}"#.utf8) + ) + #expect(decoded.payload?["some_key"] == 1) + } + @Test func strictRecordDecodesKnownKeys() throws { let data = Data( From 4edfc19abe2b821075c51e6d9da5e95bb2a366db Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 22:27:46 -0500 Subject: [PATCH 46/50] Handle strict mixed fallback scopes Keep nested strict wrappers and deferred validation inside optional/default fallback scopes while preserving delegated payload validation. Add regression coverage for defaults, nulls, optional dictionaries, and permissive superclass metadata.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Property/Data/DecodingFallback.swift | 92 ++++++++-- .../Tree/PropertyVariableTreeNode.swift | 173 +++++++++++------- .../StrictDecodingTests.swift | 46 +++++ 3 files changed, 225 insertions(+), 86 deletions(-) diff --git a/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift b/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift index bb25e1f8e5..0db3418e85 100644 --- a/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift +++ b/Sources/PluginCore/Variables/Property/Data/DecodingFallback.swift @@ -27,6 +27,16 @@ package enum DecodingFallback { /// value while second syntax will be used for all the other errors. case ifMissing(CodeBlockItemListSyntax, ifError: CodeBlockItemListSyntax) + /// Whether decoding uses an optional nested container. + var isOptional: Bool { + switch self { + case .throw: + false + case .onlyIfMissing, .ifMissing: + true + } + } + /// Represents container for decoding/encoding properties. typealias Container = PropertyVariableTreeNode.CodingLocation.Context .Container @@ -43,7 +53,13 @@ package enum DecodingFallback { /// some variables as direct children. /// - allowedKeys: The keys recognized by the current container, or /// `nil` when strict validation is disabled. - /// - prefixSyntax: Syntax emitted before the represented container. + /// - scopeCodingSyntax: Whether coding syntax is scoped to the + /// represented container. + /// - trailingValidationSyntax: Validation emitted after decoding. + /// - forceNonOptionalContainer: Whether optional fallback handling is + /// suppressed for the represented container. + /// - childContainerOptional: Whether child properties use an optional + /// container expression. /// - containerName: An explicit represented container name. /// - decoding: The nested container decoding /// code block generator. @@ -54,7 +70,10 @@ package enum DecodingFallback { nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, allowedKeys: [String]?, - prefixSyntax: CodeBlockItemListSyntax = [], + scopeCodingSyntax: Bool = false, + trailingValidationSyntax: CodeBlockItemListSyntax = [], + forceNonOptionalContainer: Bool = false, + childContainerOptional: Bool = false, containerName: TokenSyntax? = nil, nestedDecoding decoding: (Container) -> Generated ) -> Generated { @@ -65,7 +84,10 @@ package enum DecodingFallback { nestedContainer: nestedContainer, nestedContainerHasVariables: nestedContainerHasVariables, allowedKeys: allowedKeys, - prefixSyntax: prefixSyntax, + scopeCodingSyntax: scopeCodingSyntax, + trailingValidationSyntax: trailingValidationSyntax, + forceNonOptionalContainer: forceNonOptionalContainer, + childContainerOptional: childContainerOptional, containerName: containerName, nestedDecoding: decoding ) @@ -75,7 +97,10 @@ package enum DecodingFallback { nestedContainer: nestedContainer, nestedContainerHasVariables: nestedContainerHasVariables, allowedKeys: allowedKeys, - prefixSyntax: prefixSyntax, + scopeCodingSyntax: scopeCodingSyntax, + trailingValidationSyntax: trailingValidationSyntax, + forceNonOptionalContainer: forceNonOptionalContainer, + childContainerOptional: childContainerOptional, nestedDecoding: decoding ) } @@ -92,7 +117,13 @@ package enum DecodingFallback { /// some variables as direct children. /// - allowedKeys: The keys recognized by the current container, or /// `nil` when strict validation is disabled. - /// - prefixSyntax: Syntax emitted before the represented container. + /// - scopeCodingSyntax: Whether coding syntax is scoped to the + /// represented container. + /// - trailingValidationSyntax: Validation emitted after decoding. + /// - forceNonOptionalContainer: Whether optional fallback handling is + /// suppressed for the represented container. + /// - childContainerOptional: Whether child properties use an optional + /// container expression. /// - containerName: An explicit represented container name. /// - decoding: The nested container decoding /// code block generator. @@ -104,7 +135,10 @@ package enum DecodingFallback { nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, allowedKeys: [String]?, - prefixSyntax: CodeBlockItemListSyntax, + scopeCodingSyntax: Bool, + trailingValidationSyntax: CodeBlockItemListSyntax, + forceNonOptionalContainer: Bool, + childContainerOptional: Bool, containerName: TokenSyntax?, nestedDecoding decoding: (Container) -> Generated ) -> Generated { @@ -120,8 +154,17 @@ package enum DecodingFallback { } let cToken = containerName ?? nestedContainer ?? "container" - let container = Container(name: cToken, isOptional: isOptional) + let logicalOptional = + forceNonOptionalContainer ? false : isOptional + let container = Container( + name: cToken, + isOptional: childContainerOptional || logicalOptional + ) + let optionalContainer = nestedContainer ?? container.name let generated = decoding(container) + let generatedCodingSyntax = + scopeCodingSyntax + ? CodeBlockItemListSyntax {} : generated.codingSyntax let validation = if nestedContainer == nil { strictDecodingValidationSyntax( @@ -132,13 +175,12 @@ package enum DecodingFallback { CodeBlockItemListSyntax {} } let containerSyntax = CodeBlockItemListSyntax { - prefixSyntax if nestedContainer == nil { - let `try`: TokenSyntax = isOptional ? "try?" : "try" + let `try`: TokenSyntax = logicalOptional ? "try?" : "try" """ let \(container.name) = \(`try`) \(decoder).container(keyedBy: \(type)) """ - if !isOptional { + if !logicalOptional { validation } } @@ -156,24 +198,32 @@ package enum DecodingFallback { } let conditionalSyntax = CodeBlockItemListSyntax { - if isOptional { + if logicalOptional { try! IfExprSyntax( """ - if let \(cBinding) = \(container.name) + if let \(cBinding) = \(optionalContainer) """ ) { validation + if scopeCodingSyntax { + generated.codingSyntax + } ifSyntax + trailingValidationSyntax } else: { fallbacks } } else { + if scopeCodingSyntax { + generated.codingSyntax + } ifSyntax + trailingValidationSyntax } } return .init( containerSyntax: containerSyntax, - codingSyntax: generated.codingSyntax, + codingSyntax: generatedCodingSyntax, conditionalSyntax: conditionalSyntax ) } @@ -189,7 +239,13 @@ package enum DecodingFallback { /// some variables as direct children. /// - allowedKeys: The keys recognized by the current container, or /// `nil` when strict validation is disabled. - /// - prefixSyntax: Syntax emitted before the represented container. + /// - scopeCodingSyntax: Whether coding syntax is scoped to the + /// represented container. + /// - trailingValidationSyntax: Validation emitted after decoding. + /// - forceNonOptionalContainer: Whether optional fallback handling is + /// suppressed for the represented container. + /// - childContainerOptional: Whether child properties use an optional + /// container expression. /// - decoding: The nested container decoding /// code block generator. /// @@ -200,7 +256,10 @@ package enum DecodingFallback { nestedContainer: TokenSyntax?, nestedContainerHasVariables: Bool, allowedKeys: [String]?, - prefixSyntax: CodeBlockItemListSyntax, + scopeCodingSyntax: Bool, + trailingValidationSyntax: CodeBlockItemListSyntax, + forceNonOptionalContainer: Bool, + childContainerOptional: Bool, nestedDecoding decoding: (Container) -> Generated ) -> Generated { let nContainer = nestedContainer ?? "\(key.raw)_\(container.name)" @@ -231,7 +290,6 @@ package enum DecodingFallback { """ validation } - prefixSyntax generated.containerSyntax } conditionalSyntax = generated.conditionalSyntax @@ -265,7 +323,6 @@ package enum DecodingFallback { let \(nContainer) = ((try? \(container.syntax).decodeNil(forKey: \(key.expr))) == false) ? try \(container.syntax).nestedContainer(keyedBy: \(key.type), forKey: \(key.expr)) : nil """ } - prefixSyntax generated.containerSyntax } conditionalSyntax = CodeBlockItemListSyntax { @@ -323,7 +380,6 @@ package enum DecodingFallback { "\(containerMissing) = true" } } - prefixSyntax generated.containerSyntax } conditionalSyntax = CodeBlockItemListSyntax { diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index b95f34a1c9..1b933ae023 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -80,12 +80,6 @@ extension PropertyVariableTreeNode { } else { location } - let nestedMixedValidation: Bool = - hasMixedStrictValidation - && { - if case .container = location.context { return true } - return false - }() let strictDecoder: TokenSyntax? = if hasMixedStrictValidation, case .coder(let directDecoder, _) = directLocation.context @@ -122,17 +116,57 @@ extension PropertyVariableTreeNode { return "let \(strictDecoder) = MetaCodableStrictDecoder(decoder: \(decoder), allowedKeysByPath: \(metadata))" }() - if let strictDecoder, let deferredAllowedKeys { + let decodableChildren = children.lazy + .filter { data?.hasKey($0.key) ?? true } + let decodingFallback = decodableChildren.lazy + .flatMap(\.value.linkedVariables) + .map { variable in + switch variable.decodingFallback { + case .ifMissing where variable.dependenciesCount > 0: + return .ifMissing("", ifError: "") + case .onlyIfMissing where variable.dependenciesCount > 0: + return .onlyIfMissing("") + default: + return variable.decodingFallback + } + } + .reduce(.ifMissing([], ifError: []), +) + let mixedContainerOptional = + hasMixedStrictValidation + && (decodingFallback.isOptional + || directVariables.contains { $0.hasOptionalType } + || decodableChildren.lazy + .flatMap(\.value.linkedVariables) + .contains { $0.hasOptionalType }) + let scopeMixedValidation = hasMixedStrictValidation + let mixedValidationSyntax: CodeBlockItemListSyntax = { + guard let strictDecoder, let deferredAllowedKeys + else { return [] } let metadata = strictDecodingMetadataSyntax( ["": deferredAllowedKeys] ) - directLocation.deferValidation( + return "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" - ) + }() + if !scopeMixedValidation, !mixedValidationSyntax.isEmpty { + directLocation.deferValidation(mixedValidationSyntax) } + let mixedContainerName: TokenSyntax? = + hasMixedStrictValidation + ? context.makeUniqueName("strictContainer") : nil + let mixedContainerOptionalName: TokenSyntax? = + mixedContainerOptional + ? context.makeUniqueName("strictContainerOptional") : nil let decodingSyntax = CodeBlockItemListSyntax { - if !nestedMixedValidation { + if hasMixedStrictValidation, + let strictDecoder, + let mixedContainerName = + mixedContainerOptionalName ?? mixedContainerName + { wrapperSyntax + let `try`: TokenSyntax = + mixedContainerOptional ? "try?" : "try" + "let \(mixedContainerName) = \(`try`) \(strictDecoder).container(keyedBy: \(location.keyType))" } for variable in directVariables where variable.decode ?? true { @@ -168,67 +202,70 @@ extension PropertyVariableTreeNode { ) } - let decodableChildren = children.lazy - .filter { data?.hasKey($0.key) ?? true } - return decodableChildren.lazy - .flatMap(\.value.linkedVariables) - .map { variable in - switch variable.decodingFallback { - case .ifMissing where variable.dependenciesCount > 0: - return .ifMissing("", ifError: "") - case .onlyIfMissing where variable.dependenciesCount > 0: - return .onlyIfMissing("") - default: - return variable.decodingFallback - } - } - - .reduce(.ifMissing([], ifError: []), +) - .represented( - context: - nestedMixedValidation - ? directLocation.context : location.context, - nestedContainer: - location.rejectUnknownKeys + let generated = decodingFallback.represented( + context: + hasMixedStrictValidation + ? directLocation.context : location.context, + nestedContainer: + hasMixedStrictValidation + ? (mixedContainerOptionalName ?? mixedContainerName) + : (location.rejectUnknownKeys && !location.deferUnknownKeyValidation - ? nil : decodingContainer, - nestedContainerHasVariables: - location.skipCurrentContainerValidation - || !self.children.lazy - .flatMap(\.value.variables) - .filter { $0.decode ?? true }.isEmpty, - allowedKeys: allowedKeys, - prefixSyntax: nestedMixedValidation ? wrapperSyntax : [], - containerName: - nestedMixedValidation - ? context.makeUniqueName("strictContainer") : nil - ) { container in - self.decodingContainer = container.name - let generated = decodableChildren.map { cKey, node in - node.decoding( - with: data?.children[cKey], - in: context, - from: (nestedMixedValidation - ? directLocation : location).withContainer( - container, key: cKey) + ? nil : decodingContainer), + nestedContainerHasVariables: + location.skipCurrentContainerValidation + || !self.children.lazy + .flatMap(\.value.variables) + .filter { $0.decode ?? true }.isEmpty, + allowedKeys: allowedKeys, + scopeCodingSyntax: scopeMixedValidation, + trailingValidationSyntax: + scopeMixedValidation ? mixedValidationSyntax : [], + forceNonOptionalContainer: mixedContainerOptional, + childContainerOptional: mixedContainerOptional, + containerName: + mixedContainerOptional + ? mixedContainerOptionalName : nil + ) { container in + self.decodingContainer = container.name + let childContainer = + if hasMixedStrictValidation && mixedContainerOptional { + PropertyVariableTreeNode.CodingLocation.Context.Container( + name: "\(container.name)?", isOptional: false ) - }.reduce( - .init( - containerSyntax: "", codingSyntax: "", - conditionalSyntax: "" - ), + - ) - return .init( - containerSyntax: CodeBlockItemListSyntax { - generated.containerSyntax - }, - codingSyntax: decodingSyntax, - conditionalSyntax: CodeBlockItemListSyntax { - generated.codingSyntax - generated.conditionalSyntax - } + } else { + container + } + let generated = decodableChildren.map { cKey, node in + node.decoding( + with: data?.children[cKey], + in: context, + from: (hasMixedStrictValidation + ? directLocation : location).withContainer( + childContainer, key: cKey) ) - } + }.reduce( + .init( + containerSyntax: "", codingSyntax: "", + conditionalSyntax: "" + ), + + ) + return .init( + containerSyntax: CodeBlockItemListSyntax { + generated.containerSyntax + }, + codingSyntax: decodingSyntax, + conditionalSyntax: CodeBlockItemListSyntax { + generated.codingSyntax + generated.conditionalSyntax + } + ) + } + return .init( + containerSyntax: generated.containerSyntax, + codingSyntax: generated.codingSyntax, + conditionalSyntax: generated.conditionalSyntax + ) } /// Provides the code block list syntax for decoding individual diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 08a17c3032..0c7eafdd84 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -219,6 +219,24 @@ struct StrictDecodingTests { let count: Int } + @Codable(rejectUnknownKeys: true) + struct StrictMixedNestedDefaultRecord { + @Default(StrictDecodingTests.PermissivePayload(name: "fallback")) + @CodedAt("payload") + let payload: StrictDecodingTests.PermissivePayload + @Default(0) + @CodedIn("payload") + let count: Int + } + + @Codable(rejectUnknownKeys: true) + struct StrictMixedNestedOptionalRecord { + @CodedAt("payload") + let payload: StrictDecodingTests.PermissivePayload? + @CodedIn("payload") + let count: Int? + } + @Codable(rejectUnknownKeys: true) struct StrictOptionalDictionaryRecord { @CodedAt @@ -853,6 +871,34 @@ struct StrictDecodingTests { ) } + @Test + func strictMixedNestedDefaultsAndOptionalsPreserveFallbacks() throws { + let defaults = try JSONDecoder().decode( + StrictMixedNestedDefaultRecord.self, + from: Data("{}".utf8) + ) + #expect(defaults.payload.name == "fallback") + #expect(defaults.count == 0) + + let nulls = try JSONDecoder().decode( + StrictMixedNestedOptionalRecord.self, + from: Data(#"{"payload":null}"#.utf8) + ) + #expect(nulls.payload == nil) + #expect(nulls.count == nil) + + try expectDataCorrupted( + StrictMixedNestedDefaultRecord.self, + json: #"{"payload":{"name":"n","count":1,"extra":true}}"#, + containing: ["extra"] + ) + try expectDataCorrupted( + StrictMixedNestedOptionalRecord.self, + json: #"{"payload":{"name":"n","count":1,"extra":true}}"#, + containing: ["extra"] + ) + } + @Test func strictScalarClassHierarchiesRemainScalar() throws { let data = Data(#""scalar""#.utf8) From c4359aaf68651ef6d3754fb1398b0c2c6326e496 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Tue, 10 Nov 2026 23:03:52 -0500 Subject: [PATCH 47/50] Preserve mixed strict decoder scopes Keep mixed direct and keyed decoding scoped across required siblings, descendant containers, and deferred class validation. Add regression coverage for optional, descendant, and inherited mixed payloads.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tree/PropertyVariableTreeNode.swift | 53 ++++++++------- .../StrictDecodingTests.swift | 67 +++++++++++++++++++ 2 files changed, 95 insertions(+), 25 deletions(-) diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index 1b933ae023..67228e07ce 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -65,14 +65,15 @@ extension PropertyVariableTreeNode { let skipsValidation = location.skipCurrentContainerValidation || location.skipUnknownKeyValidationPaths.contains(currentPath) - let hasMixedStrictValidation = + let hasMixedStrictDecoding = location.rejectUnknownKeys - && !location.deferUnknownKeyValidation && !skipsValidation && hasDirectDecodableVariables && childrenDecodable + let hasMixedStrictValidation = + hasMixedStrictDecoding && !location.deferUnknownKeyValidation let directLocation: CodingLocation = - if hasMixedStrictValidation { + if hasMixedStrictDecoding { location.withCoder( context.makeUniqueName("strictDecoder"), preserveDecoderMetadata: true @@ -81,7 +82,7 @@ extension PropertyVariableTreeNode { location } let strictDecoder: TokenSyntax? = - if hasMixedStrictValidation, + if hasMixedStrictDecoding, case .coder(let directDecoder, _) = directLocation.context { directDecoder @@ -99,7 +100,7 @@ extension PropertyVariableTreeNode { let allowedKeys: [String]? = strictValidation ? currentAllowedKeys : nil let deferredAllowedKeys: [String]? = - hasMixedStrictValidation ? currentAllowedKeys : nil + hasMixedStrictDecoding ? currentAllowedKeys : nil let wrapperSyntax: CodeBlockItemListSyntax = { guard let strictDecoder else { return [] } let decoder: ExprSyntax @@ -132,13 +133,9 @@ extension PropertyVariableTreeNode { } .reduce(.ifMissing([], ifError: []), +) let mixedContainerOptional = - hasMixedStrictValidation - && (decodingFallback.isOptional - || directVariables.contains { $0.hasOptionalType } - || decodableChildren.lazy - .flatMap(\.value.linkedVariables) - .contains { $0.hasOptionalType }) - let scopeMixedValidation = hasMixedStrictValidation + hasMixedStrictDecoding && decodingFallback.isOptional + let scopeMixedCoding = + hasMixedStrictDecoding && decodingFallback.isOptional let mixedValidationSyntax: CodeBlockItemListSyntax = { guard let strictDecoder, let deferredAllowedKeys else { return [] } @@ -148,17 +145,17 @@ extension PropertyVariableTreeNode { return "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" }() - if !scopeMixedValidation, !mixedValidationSyntax.isEmpty { + if !hasMixedStrictValidation, !mixedValidationSyntax.isEmpty { directLocation.deferValidation(mixedValidationSyntax) } let mixedContainerName: TokenSyntax? = - hasMixedStrictValidation + hasMixedStrictDecoding ? context.makeUniqueName("strictContainer") : nil let mixedContainerOptionalName: TokenSyntax? = mixedContainerOptional ? context.makeUniqueName("strictContainerOptional") : nil let decodingSyntax = CodeBlockItemListSyntax { - if hasMixedStrictValidation, + if hasMixedStrictDecoding, let strictDecoder, let mixedContainerName = mixedContainerOptionalName ?? mixedContainerName @@ -204,10 +201,10 @@ extension PropertyVariableTreeNode { let generated = decodingFallback.represented( context: - hasMixedStrictValidation + hasMixedStrictDecoding ? directLocation.context : location.context, nestedContainer: - hasMixedStrictValidation + hasMixedStrictDecoding ? (mixedContainerOptionalName ?? mixedContainerName) : (location.rejectUnknownKeys && !location.deferUnknownKeyValidation @@ -218,9 +215,9 @@ extension PropertyVariableTreeNode { .flatMap(\.value.variables) .filter { $0.decode ?? true }.isEmpty, allowedKeys: allowedKeys, - scopeCodingSyntax: scopeMixedValidation, + scopeCodingSyntax: scopeMixedCoding, trailingValidationSyntax: - scopeMixedValidation ? mixedValidationSyntax : [], + hasMixedStrictValidation ? mixedValidationSyntax : [], forceNonOptionalContainer: mixedContainerOptional, childContainerOptional: mixedContainerOptional, containerName: @@ -229,7 +226,7 @@ extension PropertyVariableTreeNode { ) { container in self.decodingContainer = container.name let childContainer = - if hasMixedStrictValidation && mixedContainerOptional { + if hasMixedStrictDecoding && mixedContainerOptional { PropertyVariableTreeNode.CodingLocation.Context.Container( name: "\(container.name)?", isOptional: false ) @@ -240,7 +237,7 @@ extension PropertyVariableTreeNode { node.decoding( with: data?.children[cKey], in: context, - from: (hasMixedStrictValidation + from: (hasMixedStrictDecoding ? directLocation : location).withContainer( childContainer, key: cKey) ) @@ -250,11 +247,17 @@ extension PropertyVariableTreeNode { conditionalSyntax: "" ), + ) + let scopedCodingSyntax = CodeBlockItemListSyntax { + decodingSyntax + generated.containerSyntax + } return .init( - containerSyntax: CodeBlockItemListSyntax { - generated.containerSyntax - }, - codingSyntax: decodingSyntax, + containerSyntax: + hasMixedStrictDecoding + ? "" : generated.containerSyntax, + codingSyntax: + hasMixedStrictDecoding + ? scopedCodingSyntax : decodingSyntax, conditionalSyntax: CodeBlockItemListSyntax { generated.codingSyntax generated.conditionalSyntax diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 0c7eafdd84..728db2951b 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -211,6 +211,21 @@ struct StrictDecodingTests { let count: Int } + @Codable(rejectUnknownKeys: true) + struct StrictOptionalMixedDirectRecord { + @CodedAt + let payload: StrictDecodingTests.PermissivePayload? + let count: Int + } + + @Codable(rejectUnknownKeys: true) + struct StrictMixedDescendantRecord { + @CodedAt + let payload: StrictDecodingTests.PermissivePayload + @CodedIn("details") + let count: Int + } + @Codable(rejectUnknownKeys: true) struct StrictMixedNestedDirectRecord { @CodedAt("payload") @@ -418,6 +433,28 @@ struct StrictDecodingTests { } } + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: false, encodable: false) + class StrictMixedDirectBase { + @CodedAt("payload") + var payload: StrictDecodingTests.PermissivePayload = + .init(name: "") + @CodedIn("payload") + var count: Int = 0 + + init() {} + } + + @Codable(rejectUnknownKeys: true) + @Inherits(decodable: true, encodable: true) + class StrictMixedDirectChild: StrictMixedDirectBase { + var child: String = "" + + override init() { + super.init() + } + } + @Codable(rejectUnknownKeys: true) @Inherits(decodable: false, encodable: false) class StrictOptionalScalarBase { @@ -852,6 +889,23 @@ struct StrictDecodingTests { ) } + @Test + func strictMixedOptionalAndDescendantRecordsCompileAndDecode() throws { + let optional = try JSONDecoder().decode( + StrictOptionalMixedDirectRecord.self, + from: Data(#"{"name":"n","count":1}"#.utf8) + ) + #expect(optional.payload?.name == "n") + #expect(optional.count == 1) + + let descendant = try JSONDecoder().decode( + StrictMixedDescendantRecord.self, + from: Data(#"{"name":"n","details":{"count":1}}"#.utf8) + ) + #expect(descendant.payload.name == "n") + #expect(descendant.count == 1) + } + @Test func strictMixedNestedDirectAndKeyedRecordsAllowDelegatedKeys() throws { let data = Data( @@ -948,6 +1002,19 @@ struct StrictDecodingTests { #expect(decoded.child == "c") } + @Test + func strictInheritedMixedDirectPayloadPreservesMetadata() throws { + let decoded = try JSONDecoder().decode( + StrictMixedDirectChild.self, + from: Data( + #"{"payload":{"name":"n","count":1},"child":"c"}"#.utf8 + ) + ) + #expect(decoded.payload.name == "n") + #expect(decoded.count == 1) + #expect(decoded.child == "c") + } + @Test func strictOptionalDictionaryPayloadPreservesKeyDecodingStrategy() throws { let decoder = JSONDecoder() From d3ad7f7f39783a3ba278da8bec050627a35395c3 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Wed, 11 Nov 2026 00:14:58 -0500 Subject: [PATCH 48/50] Defer inherited mixed validation Preserve strict decoder observations while moving deferred mixed-container validation after superclass decoding. Add coverage for valid superclass keys in inherited mixed payloads.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...pertyVariableTreeNode+CodingLocation.swift | 22 +++++++++++ .../Variables/Type/ClassVariable.swift | 1 + .../Variables/Type/MemberGroup.swift | 39 ++++++++++++++----- .../StrictDecodingTests.swift | 5 ++- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift index 2dd01701f2..5802589a1f 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode+CodingLocation.swift @@ -219,6 +219,17 @@ extension PropertyVariableTreeNode { /// - Returns: The generated decoding syntax. func decoding( in context: some MacroExpansionContext, from location: () = () + ) -> CodeBlockItemListSyntax { + decoding( + in: context, includeDeferredValidations: true + ) + } + + /// Provides pending decoding syntax with optional deferred + /// validation emission. + func decoding( + in context: some MacroExpansionContext, + includeDeferredValidations: Bool ) -> CodeBlockItemListSyntax { let pending = progress.pending.sorted { pending1, pending2 in pending2.variable.depends(on: pending1.variable) @@ -262,6 +273,17 @@ extension PropertyVariableTreeNode { syntax } } + if includeDeferredValidations { + for validation in progress.deferredValidations { + validation + } + } + } + } + + /// Returns validations deferred by generated decoding. + func deferredDecoding() -> CodeBlockItemListSyntax { + CodeBlockItemListSyntax { for validation in progress.deferredValidations { validation } diff --git a/Sources/PluginCore/Variables/Type/ClassVariable.swift b/Sources/PluginCore/Variables/Type/ClassVariable.swift index 22985bfa9b..19aa62da46 100644 --- a/Sources/PluginCore/Variables/Type/ClassVariable.swift +++ b/Sources/PluginCore/Variables/Type/ClassVariable.swift @@ -166,6 +166,7 @@ struct ClassVariable: TypeVariable, DeclaredVariable { "let \(strictDecoder) = MetaCodableStrictDecoder(decoder: \(method.arg), allowedKeysByPath: \(metadata))" generated.code "try super.\(method.name)(\(method.argLabel): \(strictDecoder))" + group.deferredDecoding(in: context) if location.rejectUnknownKeys { "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" } diff --git a/Sources/PluginCore/Variables/Type/MemberGroup.swift b/Sources/PluginCore/Variables/Type/MemberGroup.swift index c8a4311750..8ad06c04a5 100644 --- a/Sources/PluginCore/Variables/Type/MemberGroup.swift +++ b/Sources/PluginCore/Variables/Type/MemberGroup.swift @@ -12,6 +12,11 @@ where Decl: MemberGroupSyntax & GenericTypeDeclSyntax & AttributableDeclSyntax, Decl.MemberSyntax: VariableSyntax, Decl.MemberSyntax.Variable: NamedVariable { + /// Stores the latest decoder location for deferred class validation. + private final class DecodingState { + var location: PropertyVariableTreeNode.CodingLocation? + } + /// The declaration members syntax type. typealias MemberSyntax = Decl.MemberSyntax @@ -24,6 +29,7 @@ where /// The `CodingKeys` map containing keys /// and generated case names. let codingKeys: CodingKeysMap + private let decodingState: DecodingState /// Whether direct optional decoding may receive a strict superclass /// decoder. let preservesDecoderMetadata: Bool @@ -80,6 +86,7 @@ where self.encodingNode = encodingNode self.codingKeys = codingKeys self.preservesDecoderMetadata = Decl.self == ClassDeclSyntax.self + self.decodingState = .init() } /// Provides the syntax for decoding at the provided location. @@ -97,18 +104,23 @@ where from location: TypeCodingLocation ) -> TypeGenerated? { guard let conformance = location.conformance else { return nil } + let nLocation = PropertyVariableTreeNode.CodingLocation.withCoder( + location.method.arg, keyType: codingKeys.type, + rejectUnknownKeys: location.rejectUnknownKeys, + deferUnknownKeyValidation: + location.deferUnknownKeyValidation, + preserveDecoderMetadata: + location.rejectUnknownKeys + || preservesDecoderMetadata + ) + decodingState.location = nLocation let syntax = CodeBlockItemListSyntax { - let nLocation = PropertyVariableTreeNode.CodingLocation.withCoder( - location.method.arg, keyType: codingKeys.type, - rejectUnknownKeys: location.rejectUnknownKeys, - deferUnknownKeyValidation: - location.deferUnknownKeyValidation, - preserveDecoderMetadata: - location.rejectUnknownKeys - || preservesDecoderMetadata - ) decodingNode.decoding(in: context, from: nLocation).combined() - nLocation.decoding(in: context) + nLocation.decoding( + in: context, + includeDeferredValidations: + !location.deferUnknownKeyValidation + ) } return .init( code: syntax, modifiers: [], @@ -120,6 +132,13 @@ where ) } + /// Returns validations that must run after superclass decoding. + func deferredDecoding( + in context: some MacroExpansionContext + ) -> CodeBlockItemListSyntax { + decodingState.location?.deferredDecoding() ?? "" + } + /// Provides the syntax for encoding at the provided location. /// /// If conformance type provided is `nil` no expansion performed. diff --git a/Tests/MetaCodableTests/StrictDecodingTests.swift b/Tests/MetaCodableTests/StrictDecodingTests.swift index 728db2951b..44791b915e 100644 --- a/Tests/MetaCodableTests/StrictDecodingTests.swift +++ b/Tests/MetaCodableTests/StrictDecodingTests.swift @@ -436,6 +436,7 @@ struct StrictDecodingTests { @Codable(rejectUnknownKeys: true) @Inherits(decodable: false, encodable: false) class StrictMixedDirectBase { + var base: String = "" @CodedAt("payload") var payload: StrictDecodingTests.PermissivePayload = .init(name: "") @@ -1007,11 +1008,13 @@ struct StrictDecodingTests { let decoded = try JSONDecoder().decode( StrictMixedDirectChild.self, from: Data( - #"{"payload":{"name":"n","count":1},"child":"c"}"#.utf8 + #"{"base":"b","payload":{"name":"n","count":1},"child":"c"}"# + .utf8 ) ) #expect(decoded.payload.name == "n") #expect(decoded.count == 1) + #expect(decoded.base == "b") #expect(decoded.child == "c") } From d19d62f5eba49beec7fd23b320ba1bd4da9e4695 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Wed, 11 Nov 2026 00:26:58 -0500 Subject: [PATCH 49/50] Scope deferred strict decoder handles Keep optional mixed-container decoder handles alive across deferred superclass validation and validate them only when their container exists.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tree/PropertyVariableTreeNode.swift | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift index 67228e07ce..74f23ca09c 100644 --- a/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift +++ b/Sources/PluginCore/Variables/Property/Tree/PropertyVariableTreeNode.swift @@ -101,6 +101,9 @@ extension PropertyVariableTreeNode { strictValidation ? currentAllowedKeys : nil let deferredAllowedKeys: [String]? = hasMixedStrictDecoding ? currentAllowedKeys : nil + let deferredDecoderStorage: TokenSyntax? = + location.deferUnknownKeyValidation && hasMixedStrictDecoding + ? context.makeUniqueName("deferredStrictDecoder") : nil let wrapperSyntax: CodeBlockItemListSyntax = { guard let strictDecoder else { return [] } let decoder: ExprSyntax @@ -114,8 +117,17 @@ extension PropertyVariableTreeNode { let metadata = strictDecodingMetadataSyntax( ["": deferredAllowedKeys ?? []] ) - return + let declaration = "let \(strictDecoder) = MetaCodableStrictDecoder(decoder: \(decoder), allowedKeysByPath: \(metadata))" + if let deferredDecoderStorage { + return CodeBlockItemListSyntax { + CodeBlockItemSyntax(stringLiteral: declaration) + "\(deferredDecoderStorage) = \(strictDecoder)" + } + } + return CodeBlockItemListSyntax { + CodeBlockItemSyntax(stringLiteral: declaration) + } }() let decodableChildren = children.lazy .filter { data?.hasKey($0.key) ?? true } @@ -142,6 +154,15 @@ extension PropertyVariableTreeNode { let metadata = strictDecodingMetadataSyntax( ["": deferredAllowedKeys] ) + if let deferredDecoderStorage { + return CodeBlockItemListSyntax { + try! IfExprSyntax( + "if let \(deferredDecoderStorage) = \(deferredDecoderStorage)" + ) { + "try \(deferredDecoderStorage).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" + } + } + } return "try \(strictDecoder).rejectUnknownKeys(allowedKeysByPath: \(metadata), type: Self.self)" }() @@ -265,7 +286,12 @@ extension PropertyVariableTreeNode { ) } return .init( - containerSyntax: generated.containerSyntax, + containerSyntax: CodeBlockItemListSyntax { + if let deferredDecoderStorage { + "var \(deferredDecoderStorage): MetaCodableStrictDecoder?" + } + generated.containerSyntax + }, codingSyntax: generated.codingSyntax, conditionalSyntax: generated.conditionalSyntax ) From f01ed9c4a23ecd865c04b2022c3ceb776d0f1176 Mon Sep 17 00:00:00 2001 From: Adora Lynch Date: Wed, 11 Nov 2026 08:35:26 -0500 Subject: [PATCH 50/50] Use standard strict decoder names Replace macro-mangled inherited decoder locals with project-standard strictDecoder naming and refresh affected expansion snapshots.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Variables/Type/ClassVariable.swift | 2 +- .../CodableInheritanceTests.swift | 12 ++++++------ Tests/MetaCodableTests/CodableTests.swift | 16 +++++++++------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Sources/PluginCore/Variables/Type/ClassVariable.swift b/Sources/PluginCore/Variables/Type/ClassVariable.swift index 19aa62da46..5280109080 100644 --- a/Sources/PluginCore/Variables/Type/ClassVariable.swift +++ b/Sources/PluginCore/Variables/Type/ClassVariable.swift @@ -127,7 +127,7 @@ struct ClassVariable: TypeVariable, DeclaredVariable { var modifiers: DeclModifierListSyntax = [.init(name: "required")] let method = location.method let conformance = TypeSyntax(stringLiteral: method.protocol) - let strictDecoder = context.makeUniqueName("strictDecoder") + let strictDecoder: TokenSyntax = "strictDecoder" if location.conformance == nil, implementDecodable(location: location) { let overriddenMethod = diff --git a/Tests/MetaCodableTests/CodableInheritanceTests.swift b/Tests/MetaCodableTests/CodableInheritanceTests.swift index 0615a5c239..bf2c0a307e 100644 --- a/Tests/MetaCodableTests/CodableInheritanceTests.swift +++ b/Tests/MetaCodableTests/CodableInheritanceTests.swift @@ -174,10 +174,10 @@ struct CodableInheritanceTests { override init() { super.init() } required init(from decoder: any Decoder) throws { - let __macro_local_13strictDecoderfMu_ = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) - let container = try __macro_local_13strictDecoderfMu_.container(keyedBy: CodingKeys.self) + let strictDecoder = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) + let container = try strictDecoder.container(keyedBy: CodingKeys.self) self.value = try container.decode(String.self, forKey: CodingKeys.value) - try super.init(from: __macro_local_13strictDecoderfMu_) + try super.init(from: strictDecoder) } override func encode(to encoder: any Encoder) throws { @@ -250,10 +250,10 @@ struct CodableInheritanceTests { override init() { super.init() } required init(from decoder: any Decoder) throws { - let __macro_local_13strictDecoderfMu_ = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) - let container = try __macro_local_13strictDecoderfMu_.container(keyedBy: CodingKeys.self) + let strictDecoder = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) + let container = try strictDecoder.container(keyedBy: CodingKeys.self) self.value = try container.decode(String.self, forKey: CodingKeys.value) - try super.init(from: __macro_local_13strictDecoderfMu_) + try super.init(from: strictDecoder) } func encode(to encoder: any Encoder) throws { diff --git a/Tests/MetaCodableTests/CodableTests.swift b/Tests/MetaCodableTests/CodableTests.swift index ad46aaa59b..5899b1e0f1 100644 --- a/Tests/MetaCodableTests/CodableTests.swift +++ b/Tests/MetaCodableTests/CodableTests.swift @@ -447,10 +447,10 @@ struct CodableTests { } required init(from decoder: any Decoder) throws { - let __macro_local_13strictDecoderfMu_ = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) - let container = try __macro_local_13strictDecoderfMu_.container(keyedBy: CodingKeys.self) + let strictDecoder = MetaCodableStrictDecoder(decoder: decoder, allowedKeysByPath: ["": ["value"]]) + let container = try strictDecoder.container(keyedBy: CodingKeys.self) self.value = try container.decode(String.self, forKey: CodingKeys.value) - try super.init(from: __macro_local_13strictDecoderfMu_) + try super.init(from: strictDecoder) } override func encode(to encoder: any Encoder) throws { @@ -583,7 +583,7 @@ struct CodableTests { enum Foo { case foo } - + extension Foo: Decodable { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: DecodingKeys.self) @@ -601,7 +601,7 @@ struct CodableTests { } } } - + extension Foo: Encodable { func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -611,7 +611,7 @@ struct CodableTests { } } } - + extension Foo { enum CodingKeys: String, CodingKey { case foo = "foo" @@ -640,7 +640,9 @@ struct CodableTests { func encodingToJSON() throws { let original = Foo.foo let encoded = try JSONEncoder().encode(original) - let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] + let json = + try JSONSerialization.jsonObject(with: encoded) + as? [String: Any] #expect((json?["foo"] as? [String: Any])?.isEmpty == true) } }