From a9c40e9534e98d06a6d59729be7cec608a18a0c8 Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:09:48 +0200 Subject: [PATCH] Fix cumulative schema-builder accounting and release documentation --- CHANGELOG.md | 13 +- Documentation/Architecture/SchemaAndRegex.md | 9 + Documentation/Qualification/LimitsMatrix.json | 90 ++++----- Documentation/Qualification/README.md | 9 + Examples/Consumer/Sources/Consumer/main.swift | 11 ++ README.md | 13 +- Scripts/ci.py | 2 +- .../Construction/JSONObjectAccumulator.swift | 15 +- Sources/JSON/Limits/JSONWorkMeter.swift | 12 ++ .../Construction/JSONSchemaBuilder.swift | 12 +- .../Documentation.docc/OfflineValidation.md | 7 + .../JSONSchemaTests/BuilderBudgetTests.swift | 171 ++++++++++++++++++ 12 files changed, 305 insertions(+), 59 deletions(-) create mode 100644 Tests/JSONSchemaTests/BuilderBudgetTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e7170..c0cb962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog -## 1.1.0 — unreleased candidate +## Unreleased + +- Keep object-schema construction and document admission within one cumulative + budget, including live retention and terminal owned failures/cancellation. + Tight limits that previously fit each phase separately can now fail the composed + call. Public signatures and finite default profiles are unchanged. +- Update release documentation for the published 1.1.0 package and its macOS, + iOS and iPadOS 27 baseline. + +## 1.1.0 — 2026-10-12 - Scoped typed reads with cumulative reports, native model projection and guarded cursor invalidation; explicit numeric fidelity and absent/null presence. @@ -10,7 +19,7 @@ - macOS/iOS 27 deployment minima and corrected schema bundle layout; public SDK/simulator and disk-backed SwiftData consumers. Full physical-device qualification follows the approved 1.1 matrix: physical iPhone required, iPad - SDK/simulator acceptance with physical iPad deferred. No tag/publication follows. + SDK/simulator acceptance with physical iPad deferred. - Correct native ISO-8601 calendar fields and strict Base64 unused padding bits. See API migration guidance; no limits/profile weights or dependencies raised. - Release failed Base64 scratch from logical retention while preserving consumed diff --git a/Documentation/Architecture/SchemaAndRegex.md b/Documentation/Architecture/SchemaAndRegex.md index 2a887a4..7e05678 100644 --- a/Documentation/Architecture/SchemaAndRegex.md +++ b/Documentation/Architecture/SchemaAndRegex.md @@ -6,6 +6,15 @@ These contracts describe the public behavior and its resource boundaries. JSONSchemaDocument accepts only JSON.boolean or JSON.object as the schema root. It preserves the complete raw document, optional retrieval URI and source map. A builder produces ordinary JSONSchemaDocument input. It does not bypass validation or discard unknown annotation keywords. +`JSONSchemaBuilder.object(limits:_:)` uses one schema-domain meter from the +accumulator's outer frame through document admission. Work, numeric work, aggregate +admission counters and live ownership remain charged across both phases. Admission +scratch overlaps the constructed object's live logical retention. A resource failure +or cancellation caught inside the callback remains terminal; finalization cannot +return a document from that failed invocation. Replacing the borrowed accumulator +with a different invocation fails with invalidOperation. Caller callback work is outside +owned quotas. Compilation is a separate public operation. + JSONSchemaRegistry is an immutable bounded snapshot of explicitly supplied resource documents. Updating it produces a new snapshot. There is no automatic URLSession resolver or mutable global registry. A caller fetches resources through its own authorized application I/O before constructing the snapshot. Each compile consumes the selected snapshot within the compiler's own cumulative limits. Use JSONSchemaCompiler(options:).compile(document, registry:) throws -> CompiledJSONSchema and JSONSchemaValidator(options:).validate(instance, against:) throws -> JSONValidationResult. Compiled plans are checked Sendable and immutable; validators store configuration only. Plan IDs, resource IDs, keyword IDs and reference targets use bounded private indices. Avoid exposing upstream Schema, Context, OrderedDictionary or Regex types in signatures. diff --git a/Documentation/Qualification/LimitsMatrix.json b/Documentation/Qualification/LimitsMatrix.json index b405127..17cbcf8 100644 --- a/Documentation/Qualification/LimitsMatrix.json +++ b/Documentation/Qualification/LimitsMatrix.json @@ -42,7 +42,7 @@ }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 113, + "line": 167, "code": "for metric: JSONResourceMetric in [.nodes, .aggregateTextBytes, .outputBytes, .inputBytes] { try retire(metric) }" }, { @@ -92,7 +92,7 @@ }, { "path": "Sources/JSON/Construction/JSONObjectAccumulator.swift", - "line": 7, + "line": 21, "code": "try initialMeter.require(.maxDepth, 1); try initialMeter.charge(.nodes); try initialMeter.retain(JSONLogicalWeights.node)" }, { @@ -187,7 +187,7 @@ }, { "path": "Sources/JSON/Construction/JSONObjectAccumulator.swift", - "line": 7, + "line": 21, "code": "try initialMeter.require(.maxDepth, 1); try initialMeter.charge(.nodes); try initialMeter.retain(JSONLogicalWeights.node)" }, { @@ -202,12 +202,12 @@ }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 46, + "line": 87, "code": "if metric == .nodes && domain == .schemaCompilation {" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 113, + "line": 167, "code": "for metric: JSONResourceMetric in [.nodes, .aggregateTextBytes, .outputBytes, .inputBytes] { try retire(metric) }" }, { @@ -377,7 +377,7 @@ }, { "path": "Sources/JSON/Construction/JSONObjectAccumulator.swift", - "line": 11, + "line": 33, "code": "try meter.require(.objectMembers, UInt64(members.count) + 1)" }, { @@ -542,7 +542,7 @@ }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 113, + "line": 167, "code": "for metric: JSONResourceMetric in [.nodes, .aggregateTextBytes, .outputBytes, .inputBytes] { try retire(metric) }" }, { @@ -702,42 +702,42 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 53, + "line": 94, "code": "if metric == .retainedBytes { peak = max(peak, next) }" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 80, + "line": 121, "code": "let retained = try adding(.retainedBytes,used(.retainedBytes),bytes)" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 84, + "line": 125, "code": "counters[JSONResourceMetric.retainedBytes.index] = retained; peak = max(peak,retained)" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 88, + "line": 142, "code": "guard bytes <= used(.retainedBytes) else { throw JSONFailure(.configuration, code: .invalidLimits) }" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 93, + "line": 147, "code": "counters[JSONResourceMetric.retainedBytes.index] -= bytes" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 98, + "line": 152, "code": "try checkpoint(); try require(.retainedBytes, liveBytes)" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 101, + "line": 155, "code": "counters[JSONResourceMetric.retainedBytes.index] = liveBytes" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 105, + "line": 159, "code": "if metric != .retainedBytes && metric != .regexRetainedBytes {" }, { @@ -852,17 +852,17 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 24, + "line": 45, "code": "checkpointObserver?(used(.workUnits))" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 58, + "line": 99, "code": "let total = try adding(.workUnits, used(.workUnits), amount)" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 73, + "line": 114, "code": "counters[JSONResourceMetric.workUnits.index] = total" } ], @@ -1047,7 +1047,7 @@ }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 113, + "line": 167, "code": "for metric: JSONResourceMetric in [.nodes, .aggregateTextBytes, .outputBytes, .inputBytes] { try retire(metric) }" }, { @@ -1242,7 +1242,7 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 70, + "line": 111, "code": "if amount >= limits[.cancellationQuantum] || sinceCancellationCheck >= limits[.cancellationQuantum] - amount {" }, { @@ -1337,22 +1337,22 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 47, + "line": 88, "code": "let schemaNext = try adding(.schemaNodes, used(.schemaNodes), amount)" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 48, + "line": 89, "code": "let (_, overflow) = retired[JSONResourceMetric.schemaNodes.index].addingReportingOverflow(schemaNext)" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 49, + "line": 90, "code": "guard !overflow else { throw violation(.schemaNodes, nil, overflow: true) }" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 50, + "line": 91, "code": "counters[JSONResourceMetric.schemaNodes.index] = schemaNext" } ], @@ -1392,7 +1392,7 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 61, + "line": 102, "code": "case .schemaCompilation: domainMetric = .compileWorkUnits" } ], @@ -1412,22 +1412,22 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 81, + "line": 122, "code": "let plan = try domain == .schemaCompilation ? adding(.compiledPlanBytes,used(.compiledPlanBytes),bytes) : nil" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 83, + "line": 124, "code": "if let plan { counters[JSONResourceMetric.compiledPlanBytes.index] = plan }" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 90, + "line": 144, "code": "guard bytes <= used(.compiledPlanBytes) else { throw JSONFailure(.configuration,code: .invalidLimits) }" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 91, + "line": 145, "code": "counters[JSONResourceMetric.compiledPlanBytes.index] -= bytes" } ], @@ -1447,17 +1447,17 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 62, + "line": 103, "code": "case .schemaEvaluation: domainMetric = .evaluationWorkUnits" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 69, + "line": 110, "code": "let stagedEvaluation = try compilingEvaluation && domain == .schemaCompilation ? adding(.evaluationWorkUnits, used(.evaluationWorkUnits), amount) : nil" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 76, + "line": 117, "code": "if let next = stagedEvaluation { counters[JSONResourceMetric.evaluationWorkUnits.index] = next }" } ], @@ -1542,12 +1542,12 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 121, + "line": 175, "code": "guard bytes <= used(.annotationBytes) else { throw JSONFailure(.configuration, code: .invalidLimits) }" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 122, + "line": 176, "code": "counters[JSONResourceMetric.annotationBytes.index] -= bytes" }, { @@ -1742,7 +1742,7 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 115, + "line": 169, "code": "package mutating func beginRegexSearch() throws { try retire(.regexSearchWorkUnits) }" }, { @@ -1787,17 +1787,17 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 105, + "line": 159, "code": "if metric != .retainedBytes && metric != .regexRetainedBytes {" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 117, + "line": 171, "code": "guard bytes <= used(.regexRetainedBytes) else { throw JSONFailure(.configuration, code: .invalidLimits) }" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 118, + "line": 172, "code": "counters[JSONResourceMetric.regexRetainedBytes.index] -= bytes" }, { @@ -1917,7 +1917,7 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 99, + "line": 153, "code": "let lifetime: [JSONResourceMetric] = [.totalInputBytes, .totalRecords, .totalWorkUnits, .totalScannedNodes]" }, { @@ -1947,7 +1947,7 @@ }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 99, + "line": 153, "code": "let lifetime: [JSONResourceMetric] = [.totalInputBytes, .totalRecords, .totalWorkUnits, .totalScannedNodes]" }, { @@ -2007,12 +2007,12 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 64, + "line": 105, "code": "case .streaming: domainMetric = .totalWorkUnits" }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 99, + "line": 153, "code": "let lifetime: [JSONResourceMetric] = [.totalInputBytes, .totalRecords, .totalWorkUnits, .totalScannedNodes]" } ], @@ -2167,7 +2167,7 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 63, + "line": 104, "code": "case .patch: domainMetric = .patchWorkUnits" } ], @@ -2192,7 +2192,7 @@ }, { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 99, + "line": 153, "code": "let lifetime: [JSONResourceMetric] = [.totalInputBytes, .totalRecords, .totalWorkUnits, .totalScannedNodes]" } ], @@ -2212,7 +2212,7 @@ "accounting_sites": [ { "path": "Sources/JSON/Limits/JSONWorkMeter.swift", - "line": 97, + "line": 151, "code": "resetRecord retires per-record state; selected scalar E09 retains no reusable scratch pool (zero bytes)." } ], diff --git a/Documentation/Qualification/README.md b/Documentation/Qualification/README.md index 3f6de2a..de9f3a1 100644 --- a/Documentation/Qualification/README.md +++ b/Documentation/Qualification/README.md @@ -29,6 +29,15 @@ and does not replace independent qualification or any of the 49 cap probes. Thread Sanitizer checks five shared-state cases, including concurrent payload opening and separate read scopes over one immutable root. See [QA](../Architecture/Release1.1/QA.md). +The subsequent schema-builder accounting fix adds nine public behavior functions, +for exactly 184: JSON 97, JSONStreaming 14, JSONSchema 50, JSONRegexRuntime 12 and +JSONPatch 11. The 173 parameterized invocations and earlier qualification history +remain unchanged. The new cases cover cumulative core/schema work and admission, +N/N+1 live retention, terminal caught resource failures/cancellation, caller errors, +exact keys/numbers/order, separate compilation and callback replacement of the +invocation ledger. The four-product external +consumer also checks the 150-unit composed-budget regression. + | Independent input | Observed policy | | --- | --- | | JSONTestSuite, pinned in FixtureRegistry.json | 318 discovered: 101 accepted, 186 rejected, 31 strict/finite-policy rejections | diff --git a/Examples/Consumer/Sources/Consumer/main.swift b/Examples/Consumer/Sources/Consumer/main.swift index a4c8cda..0c14257 100644 --- a/Examples/Consumer/Sources/Consumer/main.swift +++ b/Examples/Consumer/Sources/Consumer/main.swift @@ -18,6 +18,17 @@ requireSendable(JSONFailure(.syntax, code: .invalidSyntax)) let count = try exact.integer(as: Int64.self) precondition(count == 9_007_199_254_740_993) precondition(object.presence(of: "note") == .null) +var schemaLimits = JSONLimits.balanced +schemaLimits[.workUnits] = 150 +var builderFailure: JSONFailure? +do { + _ = try JSONSchemaBuilder.object(limits: schemaLimits) { builder in + try builder.append("description", value: .string(String(repeating: "x", count: 100))) + } +} catch let error as JSONFailure { builderFailure = error } +precondition(builderFailure?.resource?.metric == .workUnits) +precondition(builderFailure?.resource?.domain == .schemaCompilation) +print("SCHEMA_BUILDER_CUMULATIVE_BUDGET passed limit=150 minimum_payload_scans=200") try runREADMEExamples() try await runProductExamples() print("Consumer passed: exact values/Codable/Pointer, Patch/Merge Patch, schema documents/registry, streams/sink, reports and Sendable; immutable schema compilation/evaluation/output/inspection") diff --git a/README.md b/README.md index a3a52c0..b6535ea 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ familiar route from Swift models to the exact value model. | --- | --- | | Swift tools and compiler | 6.4 or later | | Swift language mode | 6 | -| Platform and SDK | macOS 27 or later | +| Platform and SDK | macOS 27, iOS 27 or iPadOS 27 and later | | Runtime dependencies | None outside the package and platform SDK | | License | Apache-2.0 original code, with retained upstream notices | @@ -75,7 +75,7 @@ packages. Add the package to your `Package.swift`: ```swift -.package(url: "https://github.com/ThorFuchs/Swift-JSON.git", from: "1.0.0") +.package(url: "https://github.com/ThorFuchs/Swift-JSON.git", from: "1.1.0") ``` Then add products to your target dependencies: @@ -161,12 +161,11 @@ by default; explicit ISO-8601 uses bounded UTC text with fractional seconds and four-digit years. Foreign Codable encoders/decoders determine their own numeric and key fidelity. Caller callbacks remain caller-owned work. -## Development toward 1.1 +## Scoped reads and exact persistence -The 1.1 feature branches add scoped reads, native value-coding conveniences, -exact Version 1 persistence and a measured private wide-object index. The released -1.0 package remains available through the installation declaration below. -For 1.1, iPadOS acceptance uses the SDK and iPad simulator, including disk persistence; +Release 1.1.0 includes scoped reads, native value-coding conveniences, +exact Version 1 persistence and a measured private wide-object index. +iPadOS acceptance uses the SDK and iPad simulator, including disk persistence; physical iPad qualification is deferred and nonblocking. Physical iPhone checks remain required. Simulator results make no physical-device performance claim. See the [release plan](Documentation/Architecture/Release1.1/Plan.md) and diff --git a/Scripts/ci.py b/Scripts/ci.py index 4022e19..2a260c0 100644 --- a/Scripts/ci.py +++ b/Scripts/ci.py @@ -77,7 +77,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 = [2, 3, 3, 11] if 'qa' in command else [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command or 'Tools/MobileQualification' in command else [1] if 'Tools/Qualification' in command else [1, 1, 3] if 'thread' in command else [11, 12, 14, 41, 97] + expected = [2, 3, 3, 11] if 'qa' in command else [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command or 'Tools/MobileQualification' in command else [1] if 'Tools/Qualification' in command else [1, 1, 3] if 'thread' in command else [11, 12, 14, 50, 97] passed = passed and sorted(counts) == expected if 'Tools/Qualification' in command: probes = re.findall(r'^CAP_PROBE (\w+) (\S+)$', log, re.MULTILINE) diff --git a/Sources/JSON/Construction/JSONObjectAccumulator.swift b/Sources/JSON/Construction/JSONObjectAccumulator.swift index b7df0c4..e4fdeb0 100644 --- a/Sources/JSON/Construction/JSONObjectAccumulator.swift +++ b/Sources/JSON/Construction/JSONObjectAccumulator.swift @@ -13,9 +13,13 @@ public struct JSONObjectAccumulator: ~Copyable { /// - Parameters: /// - limits: Finite inclusive capacities and work quotas for this call. public init(limits: JSONLimits = .balanced) throws { - var initialMeter = try JSONWorkMeter(limits: limits, domain: .core) + try self.init(meter: JSONWorkMeter(limits: limits, domain: .core)) + } + /// Transfers an invocation's exclusive ledger into construction. + package init(meter: consuming JSONWorkMeter) throws { + var initialMeter = meter try initialMeter.require(.maxDepth, 1); try initialMeter.charge(.nodes); try initialMeter.retain(JSONLogicalWeights.node) - meter = initialMeter + self.meter = initialMeter } /// Appends admitted payloads while retaining cumulative builder work. /// @@ -37,6 +41,13 @@ public struct JSONObjectAccumulator: ~Copyable { } /// Consumes the builder and transfers its already admitted result. public consuming func finish() -> JSONObject { JSONObject(admitted: members, index: index) } + package var continuation: JSONWorkMeter.Continuation { meter.continuation } + /// Continues an owned composition without copying or restarting its ledger. + package consuming func finish(continuation: JSONWorkMeter.Continuation, _ body: (JSONObject, inout JSONWorkMeter) throws -> Result) throws -> Result { + try continuation.check(meter) + let object = JSONObject(admitted: members, index: index) + return try body(object, &meter) + } } extension JSONObject { diff --git a/Sources/JSON/Limits/JSONWorkMeter.swift b/Sources/JSON/Limits/JSONWorkMeter.swift index 79b6332..ca49f06 100644 --- a/Sources/JSON/Limits/JSONWorkMeter.swift +++ b/Sources/JSON/Limits/JSONWorkMeter.swift @@ -5,6 +5,18 @@ private final class JSONMeterHealth { var observed = Array(repeating: UInt64(0), count: JSONResourceMetric.allCases.count) } package struct JSONWorkMeter: ~Copyable { + /// Keeps the original health alive while a public inout callback borrows an + /// accumulator. Replacing that accumulator cannot substitute a fresh ledger. + package struct Continuation { + private let health: JSONMeterHealth + fileprivate init(health: JSONMeterHealth) { self.health = health } + package func check(_ meter: borrowing JSONWorkMeter) throws { + if let failure = health.failure { throw failure } + guard health === meter.health else { throw JSONFailure(.configuration, code: .invalidOperation) } + try meter.checkpoint() + } + } + package var continuation: Continuation { Continuation(health: health) } package private(set) var limits: JSONLimits private let health = JSONMeterHealth() private let trackAcceptedCapacities: Bool diff --git a/Sources/JSONSchema/Construction/JSONSchemaBuilder.swift b/Sources/JSONSchema/Construction/JSONSchemaBuilder.swift index 5dae1da..26e3de6 100644 --- a/Sources/JSONSchema/Construction/JSONSchemaBuilder.swift +++ b/Sources/JSONSchema/Construction/JSONSchemaBuilder.swift @@ -6,14 +6,22 @@ import JSON public enum JSONSchemaBuilder { /// Builds an ordinary object schema with a checked member accumulator. /// + /// Construction and document admission share one cumulative schema budget. + /// A caught owned resource failure or cancellation remains terminal. Replacing + /// the borrowed accumulator with a different invocation is rejected. + /// /// - Throws: Duplicate keys, construction/resource failure, caller errors or cancellation. /// /// - Parameters: /// - limits: Finite inclusive capacities and work quotas for this call. /// - body: A synchronous callback borrowing the exclusive builder; its errors propagate. public static func object(limits: JSONLimits = .balanced, _ body: (inout JSONObjectAccumulator) throws -> Void) throws -> JSONSchemaDocument { - let object = try JSONObject.build(limits: limits, body) - return try JSONSchemaDocument(.object(object), limits: limits) + var accumulator = try JSONObjectAccumulator(meter: JSONWorkMeter(limits: limits, domain: .schemaCompilation)) + let continuation = accumulator.continuation + try body(&accumulator) + return try accumulator.finish(continuation: continuation) { object, meter in + try JSONSchemaDocument(.object(object), meter: &meter) + } } /// Builds an admitted Boolean schema: true accepts all instances, false rejects all. /// diff --git a/Sources/JSONSchema/Documentation.docc/OfflineValidation.md b/Sources/JSONSchema/Documentation.docc/OfflineValidation.md index 8884cd0..3901c47 100644 --- a/Sources/JSONSchema/Documentation.docc/OfflineValidation.md +++ b/Sources/JSONSchema/Documentation.docc/OfflineValidation.md @@ -9,6 +9,13 @@ only `$defs` does not automatically select a definition for instance validation. Create a root `$ref` to the definition you intend to validate. Raw declarations, unknown metadata and exact number constraints remain available for inspection. +``JSONSchemaBuilder`` constructs the same ordinary declarations. Its object helper +shares a cumulative budget across member construction and document admission, +including live object state overlapping admission scratch. Catching an owned quota +failure or cancellation in the callback cannot make finalization succeed. Replacing +the borrowed accumulator with a new invocation is rejected. Compilation +remains a separate call; callback execution itself is caller-owned work. + Supply external documents with unambiguous retrieval identities through ``JSONSchemaRegistry``. Compilation never fetches a URL. Packaged official meta-schemas are pinned and integrity-checked. Required unsupported vocabularies diff --git a/Tests/JSONSchemaTests/BuilderBudgetTests.swift b/Tests/JSONSchemaTests/BuilderBudgetTests.swift new file mode 100644 index 0000000..37944d8 --- /dev/null +++ b/Tests/JSONSchemaTests/BuilderBudgetTests.swift @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: Apache-2.0 +import Testing +import JSON +import JSONSchema + +@Test func schemaBuilderSharesConstructionAndDocumentAdmissionWork() throws { + var limits = JSONLimits.balanced + limits[.workUnits] = 150 + // Each phase scans these 100 payload bytes; neither may restart the quota. + let payload = String(repeating: "x", count: 100) + var document: JSONSchemaDocument? + var failure: JSONFailure? + do { + document = try JSONSchemaBuilder.object(limits: limits) { builder in + try builder.append("description", value: .string(payload)) + } + } catch let error as JSONFailure { failure = error } + #expect(document == nil) + #expect(failure?.code == .limitExceeded) + #expect(failure?.resource?.metric == .workUnits) + #expect(failure?.resource?.domain == .schemaCompilation) +} + +@Test func schemaBuilderSharesSchemaDomainWorkAtInclusiveBoundary() throws { + // The fixed one-member vector charges 238 primitive units across both phases. + for metric: JSONResourceMetric in [.workUnits, .compileWorkUnits] { + var limits = JSONLimits.balanced + limits[metric] = 238 + let accepted = try JSONSchemaBuilder.object(limits: limits) { builder in + try builder.append("description", value: .string(String(repeating: "x", count: 100))) + } + #expect(accepted.raw.objectValue?.count == 1) + limits[metric] = 237 + do { + _ = try JSONSchemaBuilder.object(limits: limits) { builder in + try builder.append("description", value: .string(String(repeating: "x", count: 100))) + } + Issue.record("The next work unit must fail before returning a document") + } catch let error as JSONFailure { + #expect(error.resource?.metric == metric) + #expect(error.resource?.attemptedUsage == 238) + #expect(error.resource?.domain == .schemaCompilation) + } + } +} + +@Test func schemaBuilderKeepsConstructedObjectLiveDuringAdmission() throws { + // 231 logical bytes of object/key/value/index state overlap the 16-byte frame. + for metric: JSONResourceMetric in [.retainedBytes, .compiledPlanBytes] { + var limits = JSONLimits.balanced + limits[metric] = 247 + let accepted = try JSONSchemaBuilder.object(limits: limits) { builder in + try builder.append("description", value: .string(String(repeating: "x", count: 100))) + } + #expect(accepted.raw.objectValue?.count == 1) + limits[metric] = 246 + do { + _ = try JSONSchemaBuilder.object(limits: limits) { builder in + try builder.append("description", value: .string(String(repeating: "x", count: 100))) + } + Issue.record("Live object state must overlap document admission scratch") + } catch let error as JSONFailure { + #expect(error.resource?.metric == metric) + #expect(error.resource?.attemptedUsage == 247) + } + } +} + +@Test func schemaBuilderDoesNotRestartAggregateAdmissionCounters() throws { + for (metric, boundary): (JSONResourceMetric, UInt64) in [(.nodes, 4), (.schemaNodes, 4), (.aggregateTextBytes, 222)] { + var limits = JSONLimits.balanced + limits[metric] = boundary + let document = try JSONSchemaBuilder.object(limits: limits) { builder in + try builder.append("description", value: .string(String(repeating: "x", count: 100))) + } + #expect(document.raw.objectValue?.count == 1) + limits[metric] = boundary - 1 + do { + _ = try JSONSchemaBuilder.object(limits: limits) { builder in + try builder.append("description", value: .string(String(repeating: "x", count: 100))) + } + Issue.record("Aggregate admission must remain cumulative") + } catch let error as JSONFailure { #expect(error.resource?.metric == metric) } + } +} + +@Test func schemaBuilderCaughtOwnedFailureRemainsTerminal() throws { + var limits = JSONLimits.balanced + limits[.stringBytes] = 1 + var caught: JSONFailure? + do { + _ = try JSONSchemaBuilder.object(limits: limits) { builder in + do { try builder.append("description", value: "xx") } + catch let error as JSONFailure { caught = error } + } + Issue.record("Catching an owned quota error cannot return an empty schema") + } catch let error as JSONFailure { + #expect(error == caught) + #expect(error.resource?.metric == .stringBytes) + } + #expect(caught != nil) +} + +@Test func schemaBuilderCaughtCancellationRemainsTerminal() async throws { + let task = Task { + do { + _ = try JSONSchemaBuilder.object { builder in + try builder.append("type", value: "integer") + withUnsafeCurrentTask { $0?.cancel() } + do { try builder.append("description", value: "later") } + catch is CancellationError { } + } + return false + } catch is CancellationError { return true } + } + #expect(try await task.value) +} + +private enum SchemaBuilderCallerFailure: Error, Equatable { case stopped } +@Test func schemaBuilderPreservesCallerErrorsAndDuplicateChecks() throws { + #expect(throws: SchemaBuilderCallerFailure.stopped) { + try JSONSchemaBuilder.object { _ in throw SchemaBuilderCallerFailure.stopped } + } + #expect(throws: JSONFailure(.syntax, code: .duplicateKey)) { + try JSONSchemaBuilder.object { builder in + try builder.append("type", value: "integer") + try builder.append("type", value: "string") + } + } +} + +@Test func schemaBuilderPreservesExactOrderedDeclarationsAndSeparateCompilation() throws { + let number = try JSONNumber(parsing: "9007199254740993") + let metadata = try JSONObject(members: [JSONMember("é", value: 1), JSONMember("e\u{301}", value: 2)]) + let document = try JSONSchemaBuilder.object { builder in + try builder.append("type", value: "integer") + try builder.append("minimum", value: .number(number)) + try builder.append("unknown", value: .object(metadata)) + } + let object = try #require(document.raw.objectValue) + #expect(object.map { $0.key.string } == ["type", "minimum", "unknown"]) + #expect(object["minimum"]?.numberValue == number) + #expect(object["unknown"]?.objectValue?.count == 2) + let plan = try JSONSchemaCompiler().compile(document) + #expect(try JSONSchemaValidator().validate(.number(number), against: plan).isValid) + #expect(try !JSONSchemaValidator().validate(0, against: plan).isValid) +} + +@Test func schemaBuilderRejectsCallbackReplacementOfItsLedger() throws { + #expect(throws: JSONFailure(.configuration, code: .invalidOperation)) { + try JSONSchemaBuilder.object { builder in + builder = try JSONObjectAccumulator() + try builder.append("type", value: "integer") + } + } + // Matching operation domains alone cannot detect swapping nested invocations. + #expect(throws: JSONFailure(.configuration, code: .invalidOperation)) { + try JSONSchemaBuilder.object { outer in + _ = try JSONSchemaBuilder.object { inner in swap(&outer, &inner) } + } + } + var limits = JSONLimits.balanced + limits[.stringBytes] = 1 + do { + _ = try JSONSchemaBuilder.object(limits: limits) { builder in + do { try builder.append("description", value: "xx") } + catch is JSONFailure { builder = try JSONObjectAccumulator() } + } + Issue.record("Replacing a failed accumulator cannot clear its terminal error") + } catch let error as JSONFailure { #expect(error.resource?.metric == .stringBytes) } +}