Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Documentation/Architecture/Release1.1/Reading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Scoped reads and native coding

The additive public surface is JSON/JSONDocument `read(limits:_:)` and
`readWithReport(limits:_:)`, JSONReadCursor and JSONAccessFailure. Throwing read-only
String/JSONKey/Int brackets preserve typed selection. Required value/string/Boolean/
number/object/array access never coerces, defaults or manufactures null. Integer
conversion is exact; Double/Decimal require an explicit `.exact` or `.rounded`
choice. Both key overloads provide raw/scalar/model typed presence.

The supported compiler requires experimental features for the intended returned
nonescapable shape. The public implementation uses the qualified guarded fallback.
A handle owns only its bounded typed path and a synchronous non-Sendable scope.
Scope exit invalidates it and releases root/document/source/coding-state references.
A caller-thread guard runs before accessing mutable state; a cross-thread attempt
fails with configuration/invalidOperation. This introduces no actor isolation or
Sendable escape hatch. Public compiler probes and guarded misuse tests are required.

One noncopyable core-domain meter admits the full root once. Navigation, equivalent
Pointer path text, overlapping path copies, text extraction, numeric work, source
lookup and native model projection use that ledger. Dead path ownership is refunded
at the next owned boundary; work is never refunded. Paths retain literal scalar keys
and integer indices; missing intermediate parents fail at their first missing hop.
Grouping calls amortizes root admission. It does not selectively parse the input or
make all deep navigation, root admission, edits or arbitrary caller work constant-time.

Coding options temporarily select native strategies and userInfo while their limits
permanently tighten the scope's ceilings. Prior cumulative usage and already admitted
capacities must fit that restriction; live retention is checked against current live
ownership. No state/meter copy or new decoder facade resets the budget. Numeric
temporary storage is released after primitive projection. Returned values and caller
model computation retain the ordinary caller-work and retention boundaries.

Resource/cancellation failure is latched at the owned meter/scope. Even a caller
Decodable that catches an owned conversion refusal cannot certify successful scope
or native facade completion. Ordinary access/type/conversion errors can be caught
explicitly. JSONAccessFailure holds a complete bounded typed path and structured
cause, with no rejected value or document. Mapped missing members use their parent
span; replaced/unmapped roots have no invented location. Diagnostic classification
uses the cause and never renders arbitrary caller descriptions.

JSON `init(encoding:coding:)`, `decode(_:coding:)` and `decodeWithReport(_:coding:)`
delegate to the existing native facades. Natural foreign JSON Codable keeps its
representation and fidelity limitations. Exact persistence uses a separate explicit
Version 1 wrapper. No new kind, dictionary authority, defaulting, dynamic-member
lookup, mutation bracket or projection parser is introduced.

The new modern-date compatibility case exposed an existing ISO-8601 strategy bug:
the formatter selected only time fields. Both native writer and reader now explicitly
select year/month/day plus fractional UTC time. Baseline epoch-only tests could not
detect losing the calendar date. Record this as a real affected coding qualification
failure and refresh its native/safety/consumer/DocC evidence; do not redefine it as
permitted rounding or silently reduce the assertion.
3 changes: 2 additions & 1 deletion Scripts/ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def commands(lane, output):
['swift', 'build', '-c', 'release'],
['swift', 'test'], ['swift', 'test', '-c', 'release'],
['python3', '-B', 'Scripts/qualify_release_1_1.py', '--mode', 'ownership', '--output', str(output / 'ownership.json')],
['python3', '-B', 'Scripts/qualify_release_1_1.py', '--mode', 'reader', '--output', str(output / 'reader.json')],
['swift', 'run', '-c', 'release', '--package-path', 'Examples/Consumer', 'Consumer'],
['swift', 'test', '--package-path', 'Tools/MCPReadiness'],
['swift', 'run', '-c', 'release', '--package-path', 'Tools/MCPReadiness', 'MCPReadiness', str(output / 'consumer.json')],
Expand Down Expand Up @@ -68,7 +69,7 @@ def check_result(command, code, log):
counts = [int(n) for n in re.findall(r'Test run with (\d+) tests? in[^\n]*passed', log)]
passed = code == 0
if command[:2] == ['swift', 'test']:
expected = [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command else [1] if 'Tools/Qualification' in command else [1, 1, 2] if 'thread' in command else [9, 11, 12, 38, 68]
expected = [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command else [1] if 'Tools/Qualification' in command else [1, 1, 2] if 'thread' in command else [9, 11, 12, 38, 78]
passed = passed and sorted(counts) == expected
if 'Tools/Qualification' in command:
probes = re.findall(r'^CAP_PROBE (\w+) (\S+)$', log, re.MULTILINE)
Expand Down
12 changes: 11 additions & 1 deletion Scripts/qualify_release_1_1.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def verify_index_samples(samples):

def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--mode', choices=['ownership', 'sdk', 'mobile', 'index'], required=True)
parser.add_argument('--mode', choices=['ownership', 'reader', 'sdk', 'mobile', 'index'], required=True)
parser.add_argument('--output', default='.build/ci/r0/probe-receipt.json')
args = parser.parse_args()
output = ROOT / args.output
Expand Down Expand Up @@ -94,6 +94,16 @@ def run(name, command, expected=0, diagnostic=None):
run('lifetime-feature-boundary', swift + ['-typecheck', str(PROBES / 'LifetimeAnnotated.swift')], 1,
"'@_lifetime' attribute is only valid when experimental feature Lifetimes is enabled")
receipt['positive_commands'] = 3; receipt['negative_compiler_cases'] = 4
elif args.mode == 'reader':
binary = directory / 'public-reader-consumer'
imports = ['-I', str(products)]
run('public-consumer-compile', swift + imports + ['-L', str(products), '-lJSON', str(PROBES / 'PublicReaderConsumer.swift'), '-o', str(binary)])
result = run('public-consumer-run', [str(binary)])
if 'PUBLIC_READER_CONSUMER passed' not in result: raise RuntimeError('Missing public reader outcomes')
run('public-sendable-negative', swift + imports + ['-typecheck', str(PROBES / 'PublicTaskEscape.swift')], 1, "capture of 'cursor' with non-Sendable type")
run('public-task-transfer-negative', swift + imports + ['-c', str(PROBES / 'PublicTaskTransfer.swift'), '-o', str(directory / 'illegal-public.o')], 1,
"passing closure as a 'sending' parameter risks causing data races")
receipt['positive_commands'] = 2; receipt['negative_compiler_cases'] = 2
elif args.mode == 'sdk':
for sdk, target in [('macosx', 'arm64-apple-macosx27.0'), ('iphoneos', 'arm64-apple-ios27.0'), ('iphonesimulator', 'arm64-apple-ios27.0-simulator')]:
path = run(sdk + '-path', ['xcrun', '--sdk', sdk, '--show-sdk-path']).strip()
Expand Down
17 changes: 16 additions & 1 deletion Sources/JSON/Coding/JSONCodingStrategies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ public struct JSONCodingOptions: Sendable {
}
package final class JSONCodingState {
package var meter: JSONWorkMeter
package let options: JSONCodingOptions
package private(set) var options: JSONCodingOptions
package var failure: (any Error)?
package private(set) var bridgedUserInfo: [CodingUserInfoKey: Any] = [:]
private var prepared = false
private var preparedBytes: UInt64 = 0
package init(options: JSONCodingOptions, meter: consuming JSONWorkMeter) {
self.options = options; self.meter = meter
}
Expand All @@ -55,13 +56,27 @@ package final class JSONCodingState {
if !prepared {
try meter.require(.objectMembers, UInt64(options.userInfo.count))
for (key, value) in options.userInfo {
let before = meter.used(.retainedBytes)
try JSONAdmission.text(key.rawValue, key: true, meter: &meter, owned: true)
try meter.work(); try meter.retain(JSONLogicalWeights.member)
bridgedUserInfo[key] = value
preparedBytes += meter.used(.retainedBytes) - before
}
prepared = true
}
}
package func withOptions<T>(_ requested: JSONCodingOptions, _ body: () throws -> T) throws -> T {
try meter.restrict(to: requested.limits)
let oldOptions = options, oldInfo = bridgedUserInfo, oldPrepared = prepared, oldBytes = preparedBytes
options = requested; bridgedUserInfo = [:]; prepared = false; preparedBytes = 0
func restore() throws {
try meter.release(preparedBytes)
options = oldOptions; bridgedUserInfo = oldInfo; prepared = oldPrepared; preparedBytes = oldBytes
}
do {
try check(); let result = try body(); try check(); try restore(); return result
} catch { try restore(); throw error }
}
package func latch(_ action: () throws -> Void) { do { try action() } catch { if failure == nil { failure = error } } }
package func path(_ path: [any CodingKey], appending key: any CodingKey) throws -> [any CodingKey] {
try check(); try meter.require(.maxDepth, UInt64(path.count) + 1)
Expand Down
28 changes: 28 additions & 0 deletions Sources/JSON/Coding/JSONValueConveniences.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: Apache-2.0
extension JSON {
/// Encodes a Swift model directly through the existing native value coder.
/// No Foundation serialization intermediate or persistence envelope is introduced.
/// - Parameters:
/// - value: The Encodable model; caller callback errors propagate.
/// - coding: Native Date/Data strategies, Sendable context and finite limits.
/// - Throws: Native/caller coding errors, resource/configuration failure or cancellation.
public init<T: Encodable>(encoding value: T, coding: JSONCodingOptions = .init()) throws {
self = try JSONValueEncoder(options: coding).encode(value)
}
/// Decodes a Swift model with the existing native strategies and exact integers.
/// - Parameters:
/// - type: The target Decodable model.
/// - coding: Native strategies, Sendable context and finite limits.
/// - Throws: Native/caller coding errors, resource/configuration failure or cancellation.
public func decode<T: Decodable>(_ type: T.Type, coding: JSONCodingOptions = .init()) throws -> T {
try JSONValueDecoder(options: coding).decode(type, from: self)
}
/// Decodes a Swift model and reports successful owned native coding work.
/// - Parameters:
/// - type: The target Decodable model.
/// - coding: Native strategies, Sendable context and finite limits.
/// - Throws: Native/caller coding errors, resource/configuration failure or cancellation.
public func decodeWithReport<T: Decodable>(_ type: T.Type, coding: JSONCodingOptions = .init()) throws -> JSONOperationReport<T> {
try JSONValueDecoder(options: coding).decodeWithReport(type, from: self)
}
}
2 changes: 1 addition & 1 deletion Sources/JSON/Coding/JSONValueDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ package final class JSONNativeDecoder: Decoder {
guard length <= 128 else { throw DecodingError.dataCorrupted(.init(codingPath: codingPath,debugDescription: "ISO-8601 text exceeds fixed format capacity")) }
}
try state.meter.checkpoint()
let strategy = Date.ISO8601FormatStyle().timeZone(separator: .colon).time(includingFractionalSeconds: true)
let strategy = Date.ISO8601FormatStyle().year().month().day().timeZone(separator: .colon).time(includingFractionalSeconds: true)
guard let date = try? strategy.parse(string) else { throw DecodingError.dataCorrupted(.init(codingPath: codingPath, debugDescription: "Invalid ISO-8601 date")) }
try state.meter.checkpoint(); return date as! T
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/JSON/Coding/JSONValueEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ package final class JSONNativeEncoder: Encoder {
throw EncodingError.invalidValue(date,.init(codingPath: codingPath,debugDescription: "ISO-8601 year must be 0000...9999"))
}
try state.meter.retain(128); try state.meter.checkpoint()
let text = date.ISO8601Format(.iso8601.timeZone(separator: .colon).time(includingFractionalSeconds: true))
let text = date.ISO8601Format(.iso8601.year().month().day().timeZone(separator: .colon).time(includingFractionalSeconds: true))
try state.meter.checkpoint(); try set(.string(text)); try state.meter.release(128)
}
} else { try value.encode(to: self) }
Expand Down
1 change: 1 addition & 0 deletions Sources/JSON/Diagnostics/JSONDiagnostic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public struct JSONDiagnostic: Sendable, Equatable {
/// - error: The error to classify without rendering its arbitrary description.
public init(_ error: any Error) {
if let error = error as? JSONFailure { kind = .failure; failure = error }
else if let error = error as? JSONAccessFailure { kind = .failure; failure = error.cause }
else if error is CancellationError { kind = .cancellation; failure = nil }
else { kind = .callerError; failure = nil }
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/JSON/Document/JSONSourceMap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public struct JSONSourceMap: Sendable {
/// - limits: Finite inclusive capacities and work quotas for this call.
public func span(at path: [JSONLocationComponent], kind: Entry.Kind = .value, limits: JSONLimits = .balanced) throws -> JSONSourceSpan? {
var meter = try JSONWorkMeter(limits: limits, domain: .core)
return try span(at: path, kind: kind, meter: &meter)
}
package func span(at path: [JSONLocationComponent], kind: Entry.Kind = .value, meter: inout JSONWorkMeter) throws -> JSONSourceSpan? {
for entry in entries {
try meter.work()
if entry.kind != kind || entry.path.count != path.count { continue }
Expand Down
6 changes: 6 additions & 0 deletions Sources/JSON/Documentation.docc/JSON.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,9 @@ belongs exclusively to each invocation.
- ``JSONResourceMetric``
- ``JSONOperationStatistics``
- ``JSONOperationReport``

### Scoped reads

- <doc:ScopedReads>
- ``JSONReadCursor``
- ``JSONAccessFailure``
39 changes: 39 additions & 0 deletions Sources/JSON/Documentation.docc/ScopedReads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Navigate and decode within one read scope

Group related field reads under one finite cumulative budget.

```swift
import JSON

let response = try JSONParser().parse(
#"{"users":[{"name":"Ada","age":9007199254740993,"nickname":null}]}"#)
let report = try response.readWithReport { root in
let user = try root["users"][0]
let name = try user["name"].requiredString()
let age = try user["age"].requiredInteger(as: UInt64.self)
return (name, age, try user.stringPresence(of: "nickname"))
}
```

``JSONReadCursor`` brackets require a present member or valid array element. An
integer bracket never means a numeric object key. Keys are literal decoded scalar
sequences: slash, tilde, empty and dotted keys need no special bracket syntax.
Use parent presence to distinguish absence, explicit null and a typed non-null
value. Wrong kinds and incompatible models throw; they do not select a default.

Required integer conversion preserves values above 2^53 and checks the target range.
For floating point, choose the fidelity explicitly: decimal 0.1 rejects
`requiredDouble(policy: .exact)` and permits finite `.rounded` conversion.
Native model projection uses `decode(_:coding:)`; native Float/Double models keep
their existing finite-rounding semantics.

Root admission runs once. Owned path/copy/search/conversion/model work shares one
ledger, including prior work when coding limits tighten it. The report's core domain
and numeric counters describe owned operations, not caller instructions or RSS.
Catching resource failure or cancellation inside the callback cannot produce success.

Cursors are synchronous and non-Sendable. Escaped handles become unusable, and the
scope releases its root and source references. Return ordinary values or models from
the callback. Later direct collection operations follow their existing contracts.
``JSONAccessFailure`` describes ordinary failures with a complete bounded typed path;
``JSONDocument`` reads can add coherent source locations. Replaced roots have none.
13 changes: 13 additions & 0 deletions Sources/JSON/Errors/JSONAccessFailure.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
/// An ordinary read failure with a complete bounded typed path and no JSON payload.
///
/// Resource failures, limit configuration errors and cancellation keep their own
/// types. A mapped read can attach the selected value or missing member's parent
/// location to the cause. Replaced/unmapped documents never invent a location.
public struct JSONAccessFailure: Error, Sendable {
/// The stable navigation, kind or conversion failure, with an optional source location.
public let cause: JSONFailure
/// The exact attempted key/index path, including the failing hop.
public let path: [JSONLocationComponent]
package init(cause: JSONFailure, path: [JSONLocationComponent]) { self.cause = cause; self.path = path }
}
Loading