Skip to content

fix(swift): correct AnyCodable NSNumber Int and Double encode fidelity#215

Open
joshmouch wants to merge 1 commit into
microsoft:mainfrom
joshmouch:pr/swift-anycodable-nsnumber
Open

fix(swift): correct AnyCodable NSNumber Int and Double encode fidelity#215
joshmouch wants to merge 1 commit into
microsoft:mainfrom
joshmouch:pr/swift-anycodable-nsnumber

Conversation

@joshmouch

Copy link
Copy Markdown
Contributor

Fixes #123.

The bug

When wire JSON is parsed with JSONSerialization (which boxes every number and boolean as NSNumber) and the result is wrapped in AnyCodable, re-encoding through JSONEncoder corrupts the values: Int writes as true/false and Double writes as an integer. The case let bool as Bool and case let int as Int arms both match any NSNumber, and Bool is matched first.

The fix

An NSNumber guard at the top of encode(to:) inspects objCType and dispatches to the faithful primitive (boolValue for c/B, doubleValue for f/d, int64Value otherwise) before the existing switch. The type(of:) != Bool.self check keeps native Swift Bool on the existing path. This is the dispatch from the issue's "Suggested fix".

Two things worth flagging for review

  • scripts/generate-swift.ts is also edited, and it produces no change in the checked-in Swift. AnyCodable.swift is a generator scaffold (generate-swift.ts writes it only if missing), so the same fix is applied to anyCodableContent(). That template had also drifted from the on-disk file (it was missing the @unchecked Sendable rationale and the [Any]/[String: Any] equality arms); those are reconciled here so a from-scratch scaffold reproduces AnyCodable.swift byte-for-byte. The generator diff is larger than the generated-output diff only because of that drift reconciliation.
  • The AHPClient.swift JSONSerialization bypass is intentionally left in place. That path solves the inbound/decode side (numbers boxed before they reach AnyCodable), which an encode-side fix cannot reach, so the two are complementary layers, not duplicates. Removing the bypass is a separate, riskier change and is out of scope here.

Tests

AnyCodableTests covers Int, Bool, and Double round-tripped through JSONSerialization, plus a native Swift Bool and a Float-backed NSNumber. swift test: 104 passed, 0 failed.

When a [String: Any] dictionary comes from JSONSerialization, numeric and
boolean values are boxed as NSNumber. Swift pattern-matching bridges NSNumber
to Bool, Int, and Double indiscriminately, so the existing switch hit
`case let bool as Bool` for any NSNumber, encoding integer 1 as `true` and
double 1.5 as integer 1.

Add an NSNumber guard at the top of encode(to:) that inspects objCType and
dispatches faithfully: 'c'/'B' to boolValue, 'f'/'d' to doubleValue, all
others to int64Value. The `type(of:) != Bool.self` check keeps native Swift
Bool values (which are not NSNumber) on the existing path.

Apply the same fix to the generator's anyCodableContent() template and
reconcile its pre-existing drift from the on-disk file (the @unchecked
Sendable rationale and the [Any]/[String: Any] equality arms) so a fresh
scaffold reproduces AnyCodable.swift byte-for-byte.

Tests cover Int, Bool, and Double from JSONSerialization, plus a native
Swift Bool and a Float-backed NSNumber.

Fixes microsoft#123.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Swift AnyCodable.encode(to:) so that values originating from JSONSerialization (NSNumber-backed) re-encode with the correct JSON primitive type, preventing IntBool and Double→integer corruption. It also aligns the Swift code generator’s AnyCodable scaffold with the checked-in implementation and adds regression tests plus a changelog entry.

Changes:

  • Add an NSNumber fast-path in AnyCodable.encode(to:) that dispatches by objCType before Swift pattern matching.
  • Update scripts/generate-swift.ts’s AnyCodable scaffold to match the on-disk implementation.
  • Add Swift tests covering NSNumber round-trips through JSONSerialization, and document the fix in the Swift changelog.
Show a summary per file
File Description
scripts/generate-swift.ts Updates the AnyCodable scaffold template to include the NSNumber objCType dispatch and reconcile drift.
clients/swift/CHANGELOG.md Adds an Unreleased “Fixed” entry describing the NSNumber encode-fidelity fix.
clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/AnyCodableTests.swift Adds regression tests for NSNumber-backed Int/Bool/Double, plus native Bool and Float-backed NSNumber coverage.
clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift Implements the NSNumber objCType dispatch in encode(to:) ahead of Swift type pattern matching.

Copilot's findings

  • Files reviewed: 4/4 changed files
  • Comments generated: 3

Comment on lines +52 to +62
switch objCType {
case 0x63 /* 'c' */, 0x42 /* 'B' */:
try container.encode(number.boolValue)
return
case 0x66 /* 'f' */, 0x64 /* 'd' */:
try container.encode(number.doubleValue)
return
default:
try container.encode(number.int64Value)
return
}
Comment thread scripts/generate-swift.ts
Comment on lines +1646 to +1656
switch objCType {
case 0x63 /* 'c' */, 0x42 /* 'B' */:
try container.encode(number.boolValue)
return
case 0x66 /* 'f' */, 0x64 /* 'd' */:
try container.encode(number.doubleValue)
return
default:
try container.encode(number.int64Value)
return
}
Comment on lines +49 to +56
func testAnyCodableEncodePreservesFloatBackedNSNumber() throws {
// A Float-backed NSNumber (objCType 'f') must encode as a decimal, not an
// integer. This exercises the 'f' dispatch arm; the JSONSerialization path
// boxes JSON numbers as 'd'/'q' and never produces it.
let wrapped = AnyCodable(["x": NSNumber(value: Float(1.5))] as [String: Any])
let bytes = try JSONEncoder().encode(wrapped)
XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":1.5}"#)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Swift AnyCodable.encode corrupts Int to Bool (and Double to Int) for NSNumber-backed values

3 participants