diff --git a/AGENTS.md b/AGENTS.md index 443ee58..03c4ab6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,9 @@ Swift-JSON requires Swift tools/compiler 6.4+, Swift language mode 6 and macOS/SDK 27+. Read README.md, CONTRIBUTING.md and the relevant contracts in Documentation/Architecture before changing behavior. -- Keep five public products: JSON, JSONSchema, JSONStreaming, JSONPatch and JSONHTML. +- Keep six public products: JSON, JSONSchema, JSONStreaming, JSONPatch, JSONHTML and JSONBridges. JSONHTML depends on JSON only; the maintainer-approved 1.3 addition preserves historical four-product releases. + The E2-approved opt-in JSONBridges depends on JSON only and is never re-exported by core. JSONRegexRuntime is package-private. Root package dependencies remain empty. - Preserve exact bounded decimals, decoded Unicode scalar identity, ordered objects with order-independent equality, one strict UTF-8 grammar, native diff --git a/CHANGELOG.md b/CHANGELOG.md index 605fb49..0b102a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Add E2's shallow JSON parsing, checked object construction, decimal-text and + pre-parsed Pointer entry points; exact-key membership and three-state presence + composition; and direct validation from an immutable compiled schema plan. +- Add the opt-in JSONBridges product with trusted integer construction, checked + integer projection, explicitly named Double round-trip-text admission and + policy-explicit Double/Foundation Decimal projection. Core imports remain + isolated. The generic JSON.number integer overload is deferred because it + changes existing literal overload resolution. Exact-binary source conversion, + Float-family and Decimal source bridges remain deferred. + - Add typed caller diagnostics and coherent JSONDocument byte-span rendering to JSONHTML, with exact target identity, explicitly missing-parent attachment and finite cumulative annotation/path/message accounting. Rendering never asserts diff --git a/Documentation/Architecture/API.md b/Documentation/Architecture/API.md index 38c8427..bd17ce9 100644 --- a/Documentation/Architecture/API.md +++ b/Documentation/Architecture/API.md @@ -1,7 +1,7 @@ # Public API contract This document describes the implemented public interface. Native compiler symbol -graphs supply the complete declarations in the five DocC catalogs. Examples +graphs supply the complete declarations in the six DocC catalogs. Examples in the [README](../../README.md) are compiled and executed by the public consumer. ## Products and ownership @@ -9,13 +9,14 @@ in the [README](../../README.md) are compiled and executed by the public consume | Product | Main entry points | Owned implementation boundary | | --- | --- | --- | | JSON | JSON, JSONNumber, JSONObject, JSONParser, JSONWriter, JSONValueEncoder, JSONValueDecoder, JSONPointer, JSONDocument | Shared strict codec, exact decimal model, bounded traversal and native coding | +| JSONBridges | BinaryInteger.jsonNumber, FixedWidthInteger/Double/Decimal.init(jsonNumber:), Double.jsonNumberFromRoundTripText | Optional one-delegate numeric conversions; explicit fidelity, no new authority | | JSONSchema | JSONSchemaDocument, JSONSchemaRegistry, JSONSchemaCompiler, CompiledJSONSchema, JSONSchemaValidator | Offline immutable plan, invocation-owned evaluation and private native regex | | JSONStreaming | JSONEventSequence, JSONValueSequence, NDJSONValueSequence, JSONTopLevelArraySequence, JSONAsyncWriter | Exclusive pull state, shared codec, strict framing and awaited handoffs | | JSONPatch | JSONPatch, JSONPatchOperation, JSONPatchResult, JSONMergePatch | Transaction staging, exact comparison and iterative editing | | JSONHTML | JSONHTMLRenderer, JSONHTMLRenderingOptions, JSONHTMLLimits, JSONHTMLRenderReport, JSONHTMLAnnotation, JSONExportDocument, JSONExportOptions, JSONExportReport | Exact HTML, native Apple transfer, immutable prepared JSON/text/Markdown/static HTML/opt-in styled RTF and cumulative budgets | JSON imports no siblings. JSONSchema depends on JSON and package-private -JSONRegexRuntime. Streaming, Patch and HTML depend on JSON. There are five public +JSONRegexRuntime. Streaming, Patch, HTML and Bridges depend on JSON. There are six public products and no external root-package dependencies. Runtime code uses no HTTP, UI, MCP or documentation-tool dependencies. @@ -25,6 +26,14 @@ actual stored state and result types. See [concurrency](Concurrency.md). ## Exact values and construction +The additive E2 development surface is documented in the +[ergonomics crosswalk](../Ergonomics/README.md). JSON.parse forwards String, Data +and byte arrays to their respective parser overloads; buildObject, number(parsing:) +and value(at:) wrap one existing authority each. JSONObject.containsKey is an +unmetered exact-key lookup; JSONPresence.map/fold preserve all three cases and +rethrow caller errors. CompiledJSONSchema.validate evaluates its existing offline +plan. No raw-string Pointer composition or generic number factory is added. + JSON has six cases: object, array, string, number, boolean and null. Numbers compare mathematically, text/keys compare decoded Unicode scalars, and object equality ignores insertion order. JSONObject iteration and writing preserve insertion diff --git a/Documentation/Architecture/NumbersAndUnicode.md b/Documentation/Architecture/NumbersAndUnicode.md index 62edff8..5bf2222 100644 --- a/Documentation/Architecture/NumbersAndUnicode.md +++ b/Documentation/Architecture/NumbersAndUnicode.md @@ -40,7 +40,7 @@ has the target's fixed precision/exponent bounds; exact policy verifies equality JSONNumber.fromFiniteDouble preserves Swift's round-trip decimal rendering of the supplied finite binary value. It cannot recover an earlier source token or the full -infinite binary-to-decimal expansion. NaN and infinities fail. Native Codable integer +terminating decimal expansion of its binary rational. NaN and infinities fail. Native Codable integer projections are exact; Float/Double projections round to their requested finite type. Foreign coders determine the fidelity of values they supply. diff --git a/Documentation/DocumentationTooling.md b/Documentation/DocumentationTooling.md index 5278746..b46024c 100644 --- a/Documentation/DocumentationTooling.md +++ b/Documentation/DocumentationTooling.md @@ -1,7 +1,7 @@ # Building and maintaining the DocC catalogs Swift source comments are the API documentation authority. Native compiler symbol -graphs supply the declarations and links for five Swift DocC catalogs. Each catalog +graphs supply the declarations and links for six Swift DocC catalogs. Each catalog combines the product reference with curated Topics and a focused usage guide. See the [Swift DocC documentation](https://www.swift.org/documentation/docc/). @@ -19,7 +19,7 @@ python3 Scripts/ci.py --lane docs The lane records native preflight, extracts fresh public symbol graphs, requires summaries and named-parameter documentation on every authored public declaration, checks README Swift fences against their executable consumer source, runs that -consumer, and compiles all five DocC catalogs with warnings as errors. Zero public +consumer, and compiles all six DocC catalogs with warnings as errors. Zero public symbols, missing comments, stale examples or a failed catalog fail the lane. | Output | Purpose | @@ -29,12 +29,13 @@ symbols, missing comments, stale examples or a failed catalog fail the lane. | `.build/handoff/docc/JSONStreaming.doccarchive` | Streaming API and completion/backpressure guide | | `.build/handoff/docc/JSONHTML.doccarchive` | Exact formatted HTML API and rendering guide | | `.build/handoff/docc/JSONPatch.doccarchive` | Patch API and choosing/applying patches guide | +| `.build/handoff/docc/JSONBridges.doccarchive` | Opt-in native numeric conversion and fidelity guide | | `.build/handoff/docc/symbols/` | Public compiler symbol graphs grouped by product | | `.build/handoff/docc/coverage.json` | Counts and documentation-coverage failures | | `.build/handoff/docc/receipt.json` | Commands, toolchain, counts, exits and diagnostics | -| `.build/handoff/docc/compiled-catalogs.tar.gz` | All four compiled catalogs with their exact native filenames | +| `.build/handoff/docc/compiled-catalogs.tar.gz` | All six compiled catalogs with their exact native filenames | | `.build/handoff/docc/compiled-catalogs.MANIFEST.sha256` | Independently verifiable hashes for every archived catalog file | -| `.build/handoff/pages/site/` | Static site, landing page and all five references under the project base path | +| `.build/handoff/pages/site/` | Static site, landing page and all six references under the project base path | | `.build/handoff/pages/receipt.json` | Native transformations, input/output hashes and route/asset verification | Open a `.doccarchive` in Xcode for the compiled reference. To preview a source @@ -63,7 +64,7 @@ The manifest and receipt preserve file-level and archive-level digests. The docs lane uses native `docc process-archive transform-for-static-hosting` to prepare each qualified archive under `/Swift-JSON/`. A shared landing -page links all five references and guides. The build verifies product/guide routes, +page links all six references and guides. The build verifies product/guide routes, local HTML links and assets, rejects private-path/credential markers and symlinks, and records a file manifest. Only the generated site is packaged for Pages; source graphs, command logs and private maintainer records are not website content. diff --git a/Documentation/Ergonomics/README.md b/Documentation/Ergonomics/README.md new file mode 100644 index 0000000..707124a --- /dev/null +++ b/Documentation/Ergonomics/README.md @@ -0,0 +1,62 @@ +# Additive ergonomics E2 + +These development APIs are opt-in additions to the existing semantic authorities. +Existing calls remain supported. No tagged release is changed by this feature branch. + +| Task | Before | After | Authority and boundary | +| --- | --- | --- | --- | +| Strict parsing | `JSONParser(options: options).parse(input)` | `JSON.parse(input, options: options)` | Same overload/input/options, including owned-copy accounting | +| Build object | `.object(try JSONObject.build(body))` | `try JSON.buildObject(body)` | Same exclusive accumulator and cumulative meter | +| Exact decimal | `.number(try JSONNumber(parsing: text))` | `try JSON.number(parsing: text)` | Same strict admission and original lexeme | +| Navigate Pointer | `try pointer.resolve(in: root)` | `try root.value(at: pointer)` | Resolution only; no hidden Pointer parsing | +| Exact key membership | `object[key] != nil` | `object.containsKey(key)` | Unmetered exact indexed lookup; null is present | +| Presence transformation | Exhaustive switch | `presence.map(transform)` / `fold(absent:null:value:)` | Exactly one case; callbacks are caller work and rethrow | +| Reuse schema plan | `try JSONSchemaValidator(options: options).validate(instance, against: plan)` | `try plan.validate(instance, options: options)` | Same evaluation; invalidity is false, operational failures throw | +| Trusted integer | `JSONNumber(integer: count)` | `count.jsonNumber()` | JSONBridges only; exact, trusted and unmetered | +| Checked integer | `try number.integer(as: UInt64.self)` | `try UInt64(jsonNumber: number)` | Same finite integral/range/sign projection | +| Adopt Double text | `try JSONNumber.fromFiniteDouble(value)` | `try value.jsonNumberFromRoundTripText()` | Same finite shortest round-trip decimal text, not exact binary rational | +| Project binary64 | `try number.doubleValue(policy: policy)` | `try Double(jsonNumber: number, policy: policy)` | Exact default, finite rounding opt-in | +| Project Foundation Decimal | `try number.decimalValue(policy: policy)` | `try Decimal(jsonNumber: number, policy: policy)` | Same fixed target precision/range and policy | + +The three full guides have public-only executable counterparts in +[ErgonomicsExamples.swift](../../Examples/Consumer/Sources/Consumer/ErgonomicsExamples.swift). +The native/Linux CI lanes verify their fences, compile old callsites, test bridge +import isolation in debug/release, and execute the consumer. README fences are +separately synchronized and executed by the documentation lane. + +- [Entry points, builders, scoped reads and presence](../../Sources/JSON/Documentation.docc/QuickEntryPoints.md) +- [Numeric fidelity and optional imports](../../Sources/JSONBridges/Documentation.docc/NumericFidelity.md) +- [Compiled-plan validation](../../Sources/JSONSchema/Documentation.docc/CompiledPlanConvenience.md) +- [Core differential cases](../../Tests/JSONTests/ErgonomicsTests.swift) +- [Bridge differential cases](../../Tests/JSONBridgesTests/ConversionTests.swift) +- [Schema differential cases](../../Tests/JSONSchemaTests/ErgonomicsTests.swift) + +## Decisions and exclusions + +The Swift 6.4 compiler accepts a generic JSON.number integer overload but selects +it for existing `.number(42)` calls, replacing the enum-case declaration. E2's +source-resolution gate therefore defers it. Keep explicit JSONNumber construction +or opt into `.number(count.jsonNumber())`. + +Only JSONKey membership is added: string literals already work and dynamic String +conversion remains explicit. No String-key overload proliferation, keys/values +copied arrays, nested aliases, umbrella imports, writer synonyms, static Codable +synonyms or raw-string Pointer composition is needed for this wave. + +JSONBridges is a separate sixth product depending on JSON only. Core imports no +leaf product and does not re-export bridges. Numeric source conversion for Float, +Float16, Float80, CGFloat and Foundation Decimal remains deferred. The existing +package-only exactBinary Double routine remains private pending separate finite +admission, retained-digit, nonfinite and extreme-subnormal correctness review. + +Any/NSNumber/dictionary/Optional bridges, implicit float/dictionary literals, +null-on-error navigation and public mutation setters are excluded. No parser, +regex, schema dialect, stream completion, Patch transaction, source map, profile, +callback ownership or cursor lifetime implementation changes are made. Existing +reports remain the route for cumulative successful accounting; errors/cancellation +are terminal when no successful report accounts for the work. Logical bytes are +not RSS and owned work units do not meter arbitrary caller computation. + +No runtime speed, zero-allocation or measured human-usability improvement is +claimed. Compiler symbol graphs and compiled task examples establish surface +shape and validity; platform execution receipts establish only their stated scope. diff --git a/Examples/Consumer/Package.swift b/Examples/Consumer/Package.swift index f0d9465..29ce69a 100644 --- a/Examples/Consumer/Package.swift +++ b/Examples/Consumer/Package.swift @@ -12,7 +12,8 @@ let package = Package( .product(name: "JSONSchema", package: "Swift-JSON"), .product(name: "JSONStreaming", package: "Swift-JSON"), .product(name: "JSONPatch", package: "Swift-JSON"), - .product(name: "JSONHTML", package: "Swift-JSON") + .product(name: "JSONHTML", package: "Swift-JSON"), + .product(name: "JSONBridges", package: "Swift-JSON") ])], swiftLanguageModes: [.v6] ) diff --git a/Examples/Consumer/README.md b/Examples/Consumer/README.md index aa28ca5..6309264 100644 --- a/Examples/Consumer/README.md +++ b/Examples/Consumer/README.md @@ -18,3 +18,8 @@ use the public APIs documented in README and the product DocC catalogs. The fifth product, JSONHTML, additionally checks exact highlighted output, checked Sendable reports, one admission and a cumulative work refusal using public APIs. + +The sixth optional product, JSONBridges, is imported explicitly for the E2 numeric +examples. Three synchronized guide fences execute alongside all README examples. +Core-only and unsupported-Float compiler rejections are checked separately by +Scripts/qualify_ergonomics.py in debug and release configurations. diff --git a/Examples/Consumer/Sources/Consumer/ErgonomicsExamples.swift b/Examples/Consumer/Sources/Consumer/ErgonomicsExamples.swift new file mode 100644 index 0000000..5355190 --- /dev/null +++ b/Examples/Consumer/Sources/Consumer/ErgonomicsExamples.swift @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +// Public-only before/after examples; guide fences are verified by qualify_ergonomics.py. +import Foundation +import JSON +import JSONBridges +import JSONSchema + +func runE2CoreExamples() throws { + let source = #"{"n":9007199254740993,"label":null}"# + let before = try JSONParser().parse(source) + let after = try JSON.parse(source) + precondition(before == after) + let built = try JSON.buildObject { try $0.append("n", value: 42) } + let builtBefore = JSON.object(try JSONObject.build { try $0.append("n", value: 42) }) + precondition(built == builtBefore) + let decimal = try JSON.number(parsing: "0.1") + let decimalBefore = JSON.number(try JSONNumber(parsing: "0.1")) + precondition(decimal == decimalBefore) + let pointer = try JSONPointer(parsing: "/n") + let selected = try after.value(at: pointer) + let selectedBefore = try pointer.resolve(in: before) + precondition(selected == selectedBefore) + let object = try after.read { try $0.requiredObject() } + precondition(object.containsKey("label") && !object.containsKey("missing")) + let label = try after.presence(of: "label").map { try $0.decode(String.self) } + precondition(label.fold(absent: { "missing" }, null: { "null" }, value: { $0 }) == "null") + let scoped = try after.readWithReport { try $0["n"].requiredInteger(as: UInt64.self) } + precondition(scoped.result == 9_007_199_254_740_993 && scoped.statistics[.workUnits] > 0) +} + +func runE2BridgeExamples() throws { + let count: UInt64 = 9_007_199_254_740_993 + let before = JSONNumber(integer: count) + let after = count.jsonNumber() // Trusted construction, without an operation meter. + precondition(before == after) + let recovered = try UInt64(jsonNumber: after) + precondition(recovered == count) + let tenth = try JSONNumber(parsing: "0.1") + let rounded = try Double(jsonNumber: tenth, policy: .rounded) + let adoptedText = try rounded.jsonNumberFromRoundTripText() + precondition(adoptedText == tenth) + let decimal = try Decimal(jsonNumber: tenth) + precondition(decimal == Decimal(string: "0.1")) +} + +func runE2SchemaExamples() throws { + let document = try JSONSchemaDocument(JSON.parse(#"{"type":"integer","minimum":0}"#)) + let plan = try JSONSchemaCompiler().compile(document) + let before = try JSONSchemaValidator().validate(42, against: plan) + let after = try plan.validate(42) + precondition(before.isValid && after.isValid) + let invalid = try plan.validate(-1) + precondition(!invalid.isValid) + let report = try JSONSchemaValidator().validateWithReport(42, against: plan) + precondition(report.result.isValid && report.statistics[.evaluationWorkUnits] > 0) +} diff --git a/Examples/Consumer/Sources/Consumer/READMEExamples.swift b/Examples/Consumer/Sources/Consumer/READMEExamples.swift index d4dd19c..124f41e 100644 --- a/Examples/Consumer/Sources/Consumer/READMEExamples.swift +++ b/Examples/Consumer/Sources/Consumer/READMEExamples.swift @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Compiled README examples; checked by the documentation lane. +import JSONBridges import JSON import Foundation import JSONSchema @@ -26,6 +27,20 @@ where Source.Element == JSONByteChunk { } func runREADMEExamples() throws { + let quick = try JSON.parse(#"{"n":9007199254740993,"note":null}"#) + let quickCount = try quick.read { try $0["n"].requiredInteger(as: UInt64.self) } + precondition(quickCount == 9_007_199_254_740_993) + let quickDecimal = try JSON.number(parsing: "0.1") + precondition(quickDecimal.numberValue != nil) + + let bridgeCount: UInt64 = 9_007_199_254_740_993 + let bridgeNumber = bridgeCount.jsonNumber() // Trusted, unmetered integer construction. + let bridgeRecovered = try UInt64(jsonNumber: bridgeNumber) + precondition(bridgeRecovered == bridgeCount) + let bridgeTenth = try JSONNumber(parsing: "0.1") + let bridgeRounded = try Double(jsonNumber: bridgeTenth, policy: .rounded) + precondition(bridgeRounded == 0.1) + let parser = JSONParser() let value = try parser.parse(#"{"count":9007199254740993,"note":null}"#) let count = try JSONPointer(parsing: "/count").resolve(in: value) diff --git a/Examples/Consumer/Sources/Consumer/main.swift b/Examples/Consumer/Sources/Consumer/main.swift index e107192..7a10c52 100644 --- a/Examples/Consumer/Sources/Consumer/main.swift +++ b/Examples/Consumer/Sources/Consumer/main.swift @@ -79,3 +79,8 @@ precondition(diagnosticReport.result.contains("Missing target; nearest existing let unmappedDiagnosticHTML = try diagnosticRenderer.renderString(mappedDocument.replacingRoot(value)) precondition(!unmappedDiagnosticHTML.contains("source span:")) print("JSONHTML_ANNOTATION_PUBLIC_CONSUMER passed typed_targets=2 coherent_spans=2 replaced_spans=0") + +try runE2CoreExamples() +try runE2BridgeExamples() +try runE2SchemaExamples() +print("ERGONOMICS_PUBLIC_CONSUMER passed guides=3") diff --git a/Package.swift b/Package.swift index 4fbde36..e8a0209 100644 --- a/Package.swift +++ b/Package.swift @@ -10,10 +10,13 @@ let package = Package( .library(name: "JSONSchema", targets: ["JSONSchema"]), .library(name: "JSONStreaming", targets: ["JSONStreaming"]), .library(name: "JSONPatch", targets: ["JSONPatch"]), - .library(name: "JSONHTML", targets: ["JSONHTML"]) + .library(name: "JSONHTML", targets: ["JSONHTML"]), + .library(name: "JSONBridges", targets: ["JSONBridges"]) ], targets: [ .target(name: "JSON"), + .target(name: "JSONBridges", dependencies: ["JSON"]), + .testTarget(name: "JSONBridgesTests", dependencies: ["JSONBridges", "JSON"]), .target(name: "JSONRegexRuntime", dependencies: ["JSON"]), .target(name: "JSONSchema", dependencies: ["JSON", "JSONRegexRuntime"], resources: [.copy("Resources/Draft2020-12")]), .target(name: "JSONStreaming", dependencies: ["JSON"]), diff --git a/README.md b/README.md index 7ab8d4e..5c37627 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,10 @@ Choose the products your application needs: | **JSONSchema** | Draft 2020-12 compilation/evaluation, offline registries, bounded regex, annotations and raw declaration inspection | | **JSONStreaming** | Pull syntax events and values, strict NDJSON, root-array elements and awaited output chunks | | **JSONHTML** | Exact HTML, offline exploration and immutable native transfer/export (1.3 development) | +| **JSONBridges** | Opt-in native integer, Double and Foundation Decimal conversions (E2 development) | | **JSONPatch** | Staged RFC 6902 JSON Patch and RFC 7396 Merge Patch | -`JSONSchema`, `JSONStreaming`, `JSONPatch` and `JSONHTML` build on `JSON`. The package-private +`JSONSchema`, `JSONStreaming`, `JSONPatch`, `JSONHTML` and opt-in `JSONBridges` build on `JSON`. The package-private `JSONRegexRuntime` is an implementation detail and has no public product. The root SwiftPM dependency graph is empty. Qualification and benchmark tools are separate packages. @@ -99,10 +100,43 @@ Then add products to your target dependencies: Add `JSONStreaming`, `JSONPatch` and the 1.3 development product `JSONHTML` in the same way when needed. A local checkout can use `.package(path: "../Swift-JSON")`. The requirement above accepts compatible 1.x -releases. See the [five-product consumer](Examples/Consumer) for a complete buildable +releases. See the [six-product consumer](Examples/Consumer) for a complete buildable manifest. The website tracks `main`; release assets include compiled DocC catalogues for the exact released source. +## Quick entry points and native bridges (E2 development) + +The E2 feature branch adds a shallow discovery hub. Existing parser/writer and +report APIs remain the semantic authorities; no migration is required. + +```swift +let quick = try JSON.parse(#"{"n":9007199254740993,"note":null}"#) +let quickCount = try quick.read { try $0["n"].requiredInteger(as: UInt64.self) } +precondition(quickCount == 9_007_199_254_740_993) +let quickDecimal = try JSON.number(parsing: "0.1") +precondition(quickDecimal.numberValue != nil) +``` + +Add the optional `JSONBridges` product dependency and import it explicitly: + +```swift +import JSONBridges + +let bridgeCount: UInt64 = 9_007_199_254_740_993 +let bridgeNumber = bridgeCount.jsonNumber() // Trusted, unmetered integer construction. +let bridgeRecovered = try UInt64(jsonNumber: bridgeNumber) +precondition(bridgeRecovered == bridgeCount) +let bridgeTenth = try JSONNumber(parsing: "0.1") +let bridgeRounded = try Double(jsonNumber: bridgeTenth, policy: .rounded) +precondition(bridgeRounded == 0.1) +``` + +Exact Double projection rejects decimal 0.1. A finite Double's round-trip decimal +text is distinct from its exact binary rational. Read the +[entry-point guide](Sources/JSON/Documentation.docc/QuickEntryPoints.md), +[numeric fidelity guide](Sources/JSONBridges/Documentation.docc/NumericFidelity.md) +and [before/after crosswalk](Documentation/Ergonomics/README.md). + ## Parse, inspect and write exact values ```swift diff --git a/RELEASING.md b/RELEASING.md index 7434533..6cb011b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -6,7 +6,7 @@ or releases. Published version tags are protected against updates and deletion. 1. Require passed native, safety and documentation lanes for the exact candidate commit on macOS/SDK 27+ and Swift 6.4+ with language mode 6. -2. Review five public products (including JSONHTML for 1.3), the empty root dependency graph, compiled external +2. Review six public products (including JSONHTML and opt-in JSONBridges), the empty root dependency graph, compiled external consumers, populated pinned suites, policy exclusions, all 49 finite caps, schema/regex behavior and cancellation boundaries. 3. Review licenses, provenance, public file inventory and documentation links. diff --git a/Scripts/README.md b/Scripts/README.md index 6234e5b..3e703ac 100644 --- a/Scripts/README.md +++ b/Scripts/README.md @@ -9,11 +9,11 @@ separate from the dependency-free library. Native lanes fail on unsupported host | `python3 Scripts/preflight.py` | Actual native OS/compiler/SDK receipt | | `python3 Scripts/ci.py --lane native` | Manifests, builds, Swift Testing, consumers and suites | | `python3 Scripts/ci.py --lane safety` | Address/Thread Sanitizer and strict memory safety | -| `python3 Scripts/ci.py --lane linux` | Actual Linux preflight, all five products, exact root/cap/corpus/consumer counts | +| `python3 Scripts/ci.py --lane linux` | Actual Linux preflight, all six products, exact root/cap/corpus/consumer counts | | `python3 Scripts/ci.py --lane linux-safety` | Linux Address/Thread Sanitizer and strict memory safety | | `python3 Scripts/ci.py --lane wasm` | Matched Swift 6.4 WASI debug/release, exact root/corpus/cap/consumer counts, two-host public parity and structured resource failures | | `python3 Scripts/qualify_linux_reproduction.py --output PATH` | Actual source archive, byte-equal rebuild, clean extraction, build/tests and public consumer | -| `python3 Scripts/ci.py --lane docs` | Five DocC catalogs, authored API/parameter coverage, executed README examples and verified static Pages site | +| `python3 Scripts/ci.py --lane docs` | Six DocC catalogs, authored API/parameter coverage, executed README examples and verified static Pages site | | `python3 Scripts/build_documentation_site.py` | Transform already-qualified DocC archives and verify static routes/assets | | `python3 Scripts/qualification_watchdog.py --timeout 900 --output PATH -- COMMAND` | Process-group timeout and native receipt | | `python3 Scripts/archive_candidate.py --output PATH` | Public source archive with exact hashes and byte-equal rebuild | @@ -22,3 +22,6 @@ Fixture import and Unicode/schema generators retain pinned input hashes and licenses. They neither publish nor introduce runtime network resolution. Receipts go to `.build/ci/` and GitHub Actions artifacts. Private local records and original frozen archives stay in ignored `Artifacts/`. + +`Scripts/qualify_ergonomics.py` is part of native/Linux CI: eight real-module +compiler probes and three synchronized, executed public guide fences. diff --git a/Scripts/build_documentation.py b/Scripts/build_documentation.py index e76d51b..779a5a3 100644 --- a/Scripts/build_documentation.py +++ b/Scripts/build_documentation.py @@ -60,7 +60,7 @@ def package_catalogs(output): 'bytes': archive.stat().st_size, 'verified_files': len(records), 'manifest': str(manifest.relative_to(ROOT)), 'manifest_sha256': hashlib.sha256(manifest.read_bytes()).hexdigest(), - 'policy': 'Five native DocC archives; exact internal filenames, sorted entries and normalized tar/gzip metadata.'} + 'policy': 'Six native DocC archives; exact internal filenames, sorted entries and normalized tar/gzip metadata.'} def main(): diff --git a/Scripts/build_documentation_site.py b/Scripts/build_documentation_site.py index accc333..fa1c658 100644 --- a/Scripts/build_documentation_site.py +++ b/Scripts/build_documentation_site.py @@ -24,13 +24,15 @@ 'JSONSchema': ('Offline schema validation', 'offlinevalidation'), 'JSONStreaming': ('Completion and backpressure', 'completionandbackpressure'), 'JSONPatch': ('Choosing and applying patches', 'applyingpatches'), - 'JSONHTML': ('Exact interactive HTML', 'interactiverendering')} + 'JSONHTML': ('Exact interactive HTML', 'interactiverendering'), + 'JSONBridges': ('Opt-in native conversions', 'numericfidelity')} DESCRIPTIONS = { 'JSON': 'Exact values, strict UTF-8, native Codable, Pointer, presence and finite resource reports.', 'JSONSchema': 'Draft 2020-12, explicit offline registries, immutable plans, annotations and bounded regex.', 'JSONStreaming': 'Pull events and values, strict NDJSON, root-array completion and awaited output chunks.', 'JSONPatch': 'Transactional RFC 6902 JSON Patch and RFC 7396 Merge Patch with exact value semantics.', - 'JSONHTML': 'Exact standalone HTML with offline disclosure/search and native-byte copy/download.'} + 'JSONHTML': 'Exact standalone HTML with offline disclosure/search and native-byte copy/download.', + 'JSONBridges': 'Opt-in trusted integer construction and policy-explicit Double and Foundation Decimal projection.'} class Links(HTMLParser): diff --git a/Scripts/ci.py b/Scripts/ci.py index 6ed3220..d1e1d03 100644 --- a/Scripts/ci.py +++ b/Scripts/ci.py @@ -7,7 +7,7 @@ from testing_events import read_events, ROOT_PARAMETER_CASES ROOT = pathlib.Path(__file__).resolve().parents[1] -SHARED_TESTS = 'independentCodecFacadesAndExactValuesCanBeSharedAcrossTasks|concurrentSchemaEvaluationsHaveExclusiveDynamicRegexAndAnnotationState|sharedRegexProgramHasExclusiveConcurrentSearchState|immutableIndexedObjectCanBeSharedAcrossTasks|qaSharedPayloadAndRootHaveIndependentConcurrentReadScopes|sha256InputCapacityAndConcurrentInvocationsRemainIndependent|sharedFormattedHTMLRendererHasIndependentConcurrentInvocations|sharedAnnotatedDocumentRendererHasIndependentConcurrentInvocations|sharedExportPreparationsHaveIndependentConcurrentState|applePDFSimultaneousJobsHaveIndependentStateAndCancellation' +SHARED_TESTS = 'independentCodecFacadesAndExactValuesCanBeSharedAcrossTasks|concurrentSchemaEvaluationsHaveExclusiveDynamicRegexAndAnnotationState|sharedRegexProgramHasExclusiveConcurrentSearchState|immutableIndexedObjectCanBeSharedAcrossTasks|qaSharedPayloadAndRootHaveIndependentConcurrentReadScopes|sha256InputCapacityAndConcurrentInvocationsRemainIndependent|sharedFormattedHTMLRendererHasIndependentConcurrentInvocations|sharedAnnotatedDocumentRendererHasIndependentConcurrentInvocations|sharedExportPreparationsHaveIndependentConcurrentState|applePDFSimultaneousJobsHaveIndependentStateAndCancellation|e2SharedBridgeInputsHaveInvocationOwnedState|e2SharedCompiledPlanFacadeHasIndependentInvocationState' R0_TESTS = 'readerProbe|indexProbes|duplicateRecognitionPrecedesValueAndClosingSyntax' def snapshot(): @@ -64,6 +64,7 @@ def commands(lane, output): ['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'], + ['python3', '-B', 'Scripts/qualify_ergonomics.py'], ['swift', 'test', '--package-path', 'Tools/MobileQualification'], ['swift', 'run', '-c', 'release', '--package-path', 'Tools/MobileQualification', 'MobileQualification'], ['swift', 'test', '--package-path', 'Tools/MCPReadiness'], @@ -112,7 +113,7 @@ def check_result(command, code, log, events=None): if events is not None: counts = events['bundle_counts'] if command[:2] == ['swift', 'test']: - expected = [38] if 'export|Export|apple' in command else [14] if 'Tools/WasmAudit' in command else [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 sys.platform == 'linux' else 4, 4] if 'thread' in command else sorted([11, 12, 14, 61 if sys.platform == 'linux' else 75, 51, 107]) + expected = [38] if 'export|Export|apple' in command else [14] if 'Tools/WasmAudit' in command else [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, 2, 3 if sys.platform == 'linux' else 4, 4] if 'thread' in command else sorted([10, 11, 12, 14, 61 if sys.platform == 'linux' else 75, 55, 119]) passed = passed and sorted(counts) == expected if '--filter' not in command and not any(x.startswith('Tools/') for x in command): passed = passed and (events['parameterized_functions'] == ROOT_PARAMETER_CASES if events is not None else sum(int(n) for n in re.findall(r'with (\d+) test cases passed', log)) == 173) @@ -123,16 +124,18 @@ def check_result(command, code, log, events=None): passed = passed and len(probes) == 49 and dict(probes) == expected_probes if command == ['swift', 'package', 'dump-package'] and passed: d = json.loads(log) - passed = sorted(p['name'] for p in d['products']) == ['JSON', 'JSONHTML', 'JSONPatch', 'JSONSchema', 'JSONStreaming'] and not d['dependencies'] and d['swiftLanguageVersions'] == ['6'] and d['toolsVersion']['_version'] == '6.4.0' and d['platforms'] == [{'options': [], 'platformName': 'macos', 'version': '27.0'}, {'options': [], 'platformName': 'ios', 'version': '27.0'}] - passed = passed and {p['name']: p['targets'] for p in d['products']} == {name: [name] for name in ['JSON', 'JSONSchema', 'JSONStreaming', 'JSONPatch', 'JSONHTML']} + passed = sorted(p['name'] for p in d['products']) == ['JSON', 'JSONBridges', 'JSONHTML', 'JSONPatch', 'JSONSchema', 'JSONStreaming'] and not d['dependencies'] and d['swiftLanguageVersions'] == ['6'] and d['toolsVersion']['_version'] == '6.4.0' and d['platforms'] == [{'options': [], 'platformName': 'macos', 'version': '27.0'}, {'options': [], 'platformName': 'ios', 'version': '27.0'}] + passed = passed and {p['name']: p['targets'] for p in d['products']} == {name: [name] for name in ['JSON', 'JSONSchema', 'JSONStreaming', 'JSONPatch', 'JSONHTML', 'JSONBridges']} targets = {t['name']: t for t in d['targets']} - passed = passed and set(targets) == {'JSON', 'JSONSchema', 'JSONStreaming', 'JSONPatch', 'JSONHTML', 'JSONRegexRuntime', 'JSONTests', 'JSONSchemaTests', 'JSONStreamingTests', 'JSONPatchTests', 'JSONHTMLTests', 'JSONRegexRuntimeTests'} and targets['JSONHTML']['dependencies'] == [{'byName': ['JSON', None]}] + passed = passed and set(targets) == {'JSON', 'JSONBridges', 'JSONSchema', 'JSONStreaming', 'JSONPatch', 'JSONHTML', 'JSONRegexRuntime', 'JSONTests', 'JSONBridgesTests', 'JSONSchemaTests', 'JSONStreamingTests', 'JSONPatchTests', 'JSONHTMLTests', 'JSONRegexRuntimeTests'} and targets['JSONHTML']['dependencies'] == [{'byName': ['JSON', None]}] and targets['JSONBridges']['dependencies'] == [{'byName': ['JSON', None]}] if command == ['swift', 'package', 'show-dependencies', '--format', 'json'] and passed: passed = json.loads(log)['dependencies'] == [] if command[:2] == ['swift', 'run'] and 'Tools/MobileQualification' in command: passed = passed and all(line in log.splitlines() for line in ['PUBLIC_BEHAVIOR passed fixtures=8', 'STREAM_CANCELLATION passed', 'DISK_PERSISTENCE passed containers=3 rows=8', 'MOBILE_QUALIFICATION passed cases=3']) if command[:3] == ['python3', '-B', 'Scripts/verify_testing_events.py']: passed = passed and 'TESTING_EVENT_RECEIPTS passed cases=19' in log.splitlines() + if command[:3] == ['python3', '-B', 'Scripts/qualify_ergonomics.py']: + passed = passed and 'ERGONOMICS_COMPILER passed commands=8 guide_fences=3' in log.splitlines() if command[:3] == ['python3', '-B', 'Scripts/verify_html_assets.py']: passed = passed and 'JSONHTML_ASSETS passed count=2' in log.splitlines() if 'BrowserFixtures' in command: @@ -209,7 +212,7 @@ def save(): if receipt['status'] == 'passed' and args.lane == 'wasm': try: wasi = json.loads((output / 'probes/wasi.json').read_text()) - if wasi['status'] != 'passed' or wasi['root_counts'] != {'JSONTests':107,'JSONSchemaTests':51,'JSONRegexRuntimeTests':12,'JSONStreamingTests':14,'JSONPatchTests':11,'JSONHTMLTests':61}: + if wasi['status'] != 'passed' or wasi['root_counts'] != {'JSONTests':119,'JSONBridgesTests':10,'JSONSchemaTests':55,'JSONRegexRuntimeTests':12,'JSONStreamingTests':14,'JSONPatchTests':11,'JSONHTMLTests':61}: raise ValueError('Missing WASI qualification boundary') receipt['materialized_wasi'] = {'path':'probes/wasi.json','sha256':hashlib.sha256((output / 'probes/wasi.json').read_bytes()).hexdigest()} except (OSError,KeyError,ValueError,TypeError) as error: diff --git a/Scripts/documentation_catalog.py b/Scripts/documentation_catalog.py index 7b01de1..add1f6e 100644 --- a/Scripts/documentation_catalog.py +++ b/Scripts/documentation_catalog.py @@ -7,7 +7,7 @@ import urllib.parse ROOT = pathlib.Path(__file__).resolve().parents[1] -PRODUCTS = ('JSON', 'JSONSchema', 'JSONStreaming', 'JSONPatch', 'JSONHTML') +PRODUCTS = ('JSON', 'JSONSchema', 'JSONStreaming', 'JSONPatch', 'JSONHTML', 'JSONBridges') def public_symbols(graph_dir): diff --git a/Scripts/qualify_ergonomics.py b/Scripts/qualify_ergonomics.py new file mode 100644 index 0000000..f645627 --- /dev/null +++ b/Scripts/qualify_ergonomics.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Compile real public E2 callsites and prove opt-in extension isolation.""" +import hashlib +import json +import pathlib +import platform +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +OLD = '''import Foundation +import JSON +let a: JSON = .number(42) +let b = JSON.number(JSONNumber(integer: 42)) +let inferredFactory = JSON.number +let inferredValue = inferredFactory(42) +let factory: (JSONNumber) -> JSON = JSON.number +let values = [JSONNumber(integer: 1)].map(JSON.number) +if case .number(let n) = a { precondition(n == 42) } +struct Model: Codable { let n: Int } +let model = try JSON(encoding: Model(n: 1)).decode(Model.self) +''' +BRIDGES = '''import Foundation +import JSON +import JSONBridges +func convert(_ n: T) -> JSONNumber { n.jsonNumber() } +let n = UInt64.max.jsonNumber() +let recovered = try UInt64(jsonNumber: n) +let decimal = try JSONNumber(parsing: "0.1") +let rounded = try Double(jsonNumber: decimal, policy: .rounded) +let adopted = try rounded.jsonNumberFromRoundTripText() +let foundation = try Decimal(jsonNumber: decimal) +''' + + +def main(): + output = ROOT / '.build/ci/ergonomics' + output.mkdir(parents=True, exist_ok=True) + receipt = {'status': 'failed', 'commands': [], 'build_paths': [], 'guide_fences': 0} + guides = [ROOT / p for p in ( + 'Sources/JSON/Documentation.docc/QuickEntryPoints.md', + 'Sources/JSONBridges/Documentation.docc/NumericFidelity.md', + 'Sources/JSONSchema/Documentation.docc/CompiledPlanConvenience.md')] + import re + consumer = (ROOT / 'Examples/Consumer/Sources/Consumer/ErgonomicsExamples.swift').read_text() + for guide in guides: + blocks = re.findall(r'```swift\n(.*?)\n```', guide.read_text(), re.S) + if len(blocks) != 1: + raise ValueError('Expected one executed guide fence: ' + str(guide)) + indented = '\n'.join(' ' + line for line in blocks[0].splitlines()) + if indented not in consumer: + raise ValueError('Guide differs from public executable: ' + str(guide)) + receipt['guide_fences'] += 1 + for configuration in ('debug', 'release'): + # Ask SwiftPM rather than guessing Debug/Debug-linux/triple directories. + path_command = ['swift', 'build', '-c', configuration, '--show-bin-path'] + products = pathlib.Path(subprocess.check_output(path_command, cwd=ROOT, text=True).strip()) + if not products.is_dir() or not products.is_relative_to(ROOT / '.build'): + raise ValueError('Invalid SwiftPM products path: ' + str(products)) + include = [products / 'Modules' if (products / 'Modules').is_dir() else products] + for module in ('JSON', 'JSONBridges'): + path = include[0] / (module + '.swiftmodule') + if not (path.is_file() or (path.is_dir() and any(path.glob('*.swiftmodule')))): + raise ValueError('Missing built module: ' + str(path)) + receipt['build_paths'].append({'command': path_command, 'products': str(products)}) + flags = ['swiftc', '-swift-version', '6', '-strict-concurrency=complete', '-warnings-as-errors'] + if sys.platform == 'darwin': + sdk = subprocess.check_output(['xcrun', '--sdk', 'macosx', '--show-sdk-path'], text=True).strip() + flags += ['-sdk', sdk, '-target', platform.machine() + '-apple-macosx27.0'] + for path in sorted(set(include)): + flags += ['-I', str(path)] + for name, source, expected, diagnostic in ( + ('old-calls', OLD, 0, None), ('bridges', BRIDGES, 0, None), + ('no-bridge-import', 'import JSON\nlet _ = (42 as Int).jsonNumber()\n', 1, 'jsonNumber'), + ('no-float-bridge', 'import JSON\nimport JSONBridges\nlet _ = Float(0.1).jsonNumberFromRoundTripText()\n', 1, 'jsonNumberFromRoundTripText')): + stem = configuration + '-' + name + source_path = output / (stem + '.swift') + source_path.write_text(source) + command = flags + ['-typecheck'] + (['-O'] if configuration == 'release' else []) + [str(source_path)] + result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True) + log = output / (stem + '.log'); log.write_text(result.stdout + result.stderr) + passed = (result.returncode == 0) == (expected == 0) + if diagnostic: + passed = passed and 'has no member' in result.stderr and diagnostic in result.stderr + receipt['commands'].append({'command': command, 'exit_code': result.returncode, + 'expected_rejection': expected != 0, 'status': 'passed' if passed else 'failed', + 'log': log.relative_to(ROOT).as_posix(), 'log_sha256': hashlib.sha256(log.read_bytes()).hexdigest(), + 'source_sha256': hashlib.sha256(source_path.read_bytes()).hexdigest()}) + if not passed: + (output / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + raise ValueError('Compiler contract failed: ' + stem) + receipt['status'] = 'passed' + (output / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + print('ERGONOMICS_COMPILER passed commands=8 guide_fences=3') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/Scripts/wasm_support.py b/Scripts/wasm_support.py index 74efe58..392473e 100644 --- a/Scripts/wasm_support.py +++ b/Scripts/wasm_support.py @@ -6,7 +6,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] SDK = 'swift-6.4.0-RELEASE_wasm' -COUNTS = {'JSONTests': 107, 'JSONSchemaTests': 51, 'JSONRegexRuntimeTests': 12, +COUNTS = {'JSONTests': 119, 'JSONBridgesTests': 10, 'JSONSchemaTests': 55, 'JSONRegexRuntimeTests': 12, 'JSONStreamingTests': 14, 'JSONPatchTests': 11, 'JSONHTMLTests': 61} GROUPS = {'core': 6, 'resources': 1, 'schema': 2, 'streaming': 3, 'html': 6} @@ -54,7 +54,7 @@ def binary(scratch, config, name): run('sdk', [swift, 'sdk', 'list'], lambda c, t: c == 0 and SDK in t.splitlines()) run('wasmkit-version', [wasmkit, '--version']); run('node-version', [node, '--version']) manifest = json.loads(run('root-manifest', [swift, 'package', 'dump-package'])) - if manifest['dependencies'] or sorted(p['name'] for p in manifest['products']) != ['JSON', 'JSONHTML', 'JSONPatch', 'JSONSchema', 'JSONStreaming']: + if manifest['dependencies'] or sorted(p['name'] for p in manifest['products']) != ['JSON', 'JSONBridges', 'JSONHTML', 'JSONPatch', 'JSONSchema', 'JSONStreaming']: raise ValueError('Changed public products/dependencies') def invoke(engine, path, mode='command', arguments=(), repo=True, resources=True): if engine == 'node': diff --git a/Sources/JSON/Documentation.docc/JSON.md b/Sources/JSON/Documentation.docc/JSON.md index e154e06..7be7ee0 100644 --- a/Sources/JSON/Documentation.docc/JSON.md +++ b/Sources/JSON/Documentation.docc/JSON.md @@ -54,6 +54,7 @@ belongs exclusively to each invocation. ### Guides - +- - ### Exact model diff --git a/Sources/JSON/Documentation.docc/QuickEntryPoints.md b/Sources/JSON/Documentation.docc/QuickEntryPoints.md new file mode 100644 index 0000000..f384626 --- /dev/null +++ b/Sources/JSON/Documentation.docc/QuickEntryPoints.md @@ -0,0 +1,50 @@ +# Quick entry points and presence + +Start with the JSON value type for common tasks, then use the owning facade for +reports, source documents, writing, coding strategies or a reusable configuration. +These additive E2 methods are in development until their feature branch merges. + +```swift +let source = #"{"n":9007199254740993,"label":null}"# +let before = try JSONParser().parse(source) +let after = try JSON.parse(source) +precondition(before == after) +let built = try JSON.buildObject { try $0.append("n", value: 42) } +let builtBefore = JSON.object(try JSONObject.build { try $0.append("n", value: 42) }) +precondition(built == builtBefore) +let decimal = try JSON.number(parsing: "0.1") +let decimalBefore = JSON.number(try JSONNumber(parsing: "0.1")) +precondition(decimal == decimalBefore) +let pointer = try JSONPointer(parsing: "/n") +let selected = try after.value(at: pointer) +let selectedBefore = try pointer.resolve(in: before) +precondition(selected == selectedBefore) +let object = try after.read { try $0.requiredObject() } +precondition(object.containsKey("label") && !object.containsKey("missing")) +let label = try after.presence(of: "label").map { try $0.decode(String.self) } +precondition(label.fold(absent: { "missing" }, null: { "null" }, value: { $0 }) == "null") +let scoped = try after.readWithReport { try $0["n"].requiredInteger(as: UInt64.self) } +precondition(scoped.result == 9_007_199_254_740_993 && scoped.statistics[.workUnits] > 0) +``` + +`JSON.parse` forwards each input kind and its options to the same parser overload. +String/Data parsing charges the delegate's owned input copy; a byte array follows +its own existing admission boundary. Do not compare counters across input kinds. +`buildObject` borrows the same noncopyable accumulator as `JSONObject.build` and +wraps its admitted result without another admission or meter. Callback work is +caller work. `number(parsing:)` keeps strict decimal tokens and their lexemes. + +Pointer parsing is an explicit separate operation. `value(at:)` delegates only +resolution and does not hide a parse with a fresh quota. Use `readWithReport` for +selection, conversion and native model projection under one scoped meter. +An escaped read cursor remains invalid after scope exit; no new cursor API is added. + +`containsKey` is an unmetered exact-key lookup. Null is present. Literals use +JSONKey; wrap a dynamic String explicitly in JSONKey. Swift String dictionaries +cannot preserve all JSON-distinct scalar keys. No dictionary bridge is added. +`map` transforms only a non-null payload; `fold` runs exactly one branch. Their +throwing callbacks propagate unchanged and are caller-owned work. + +Keep `JSON.number(JSONNumber(integer: dynamic))` for trusted integers. A generic +same-named overload was deferred because Swift 6.4 changed the selected declaration +for existing `.number(42)` calls. No nested Pointer/schema/HTML aliases are added. diff --git a/Sources/JSON/Value/JSONEntryPoints.swift b/Sources/JSON/Value/JSONEntryPoints.swift new file mode 100644 index 0000000..a482b3d --- /dev/null +++ b/Sources/JSON/Value/JSONEntryPoints.swift @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation + +extension JSON { + /// Parses one strict document, including trailing whitespace and EOF. + /// + /// Delegates to `JSONParser.parse(_:)` with the same String input and options. + /// Use the parser's report or document methods for accounting or source maps. + /// - Parameters: + /// - text: JSON source text, without permissive syntax or Unicode normalization. + /// - options: Finite parsing limits forwarded unchanged to the parser. + /// - Returns: The admitted exact root. + /// - Throws: The parser's syntax, resource or configuration error, or cancellation. + public static func parse(_ text: String, options: JSONParsingOptions = .init()) throws -> JSON { + try JSONParser(options: options).parse(text) + } + + /// Parses one strict UTF-8 document from Data, including EOF. + /// + /// Delegates to `JSONParser.parse(_:)` with the same Data input and options. + /// - Parameters: + /// - data: Encoded JSON bytes; malformed UTF-8 and duplicate decoded keys fail. + /// - options: Finite parsing limits forwarded unchanged to the parser. + /// - Returns: The admitted exact root. + /// - Throws: The parser's syntax, resource or configuration error, or cancellation. + public static func parse(_ data: Data, options: JSONParsingOptions = .init()) throws -> JSON { + try JSONParser(options: options).parse(data) + } + + /// Parses one strict UTF-8 document from bytes, including EOF. + /// + /// Delegates to `JSONParser.parse(_:)` with the same array and options. + /// - Parameters: + /// - bytes: Encoded JSON bytes; no replacement decoding is performed. + /// - options: Finite parsing limits forwarded unchanged to the parser. + /// - Returns: The admitted exact root. + /// - Throws: The parser's syntax, resource or configuration error, or cancellation. + public static func parse(_ bytes: [UInt8], options: JSONParsingOptions = .init()) throws -> JSON { + try JSONParser(options: options).parse(bytes) + } + + /// Builds an exact ordered object using one exclusive checked accumulator. + /// + /// Delegates once to `JSONObject.build(limits:_:)`, then wraps its result. + /// Appends share its cumulative budget; callback computation is caller work. + /// - Parameters: + /// - limits: Finite construction limits forwarded unchanged to the builder. + /// - body: A synchronous callback borrowing the noncopyable accumulator. + /// - Returns: The built object as a JSON value. + /// - Throws: Duplicate keys, resource failure, cancellation or the callback's error. + public static func buildObject(limits: JSONLimits = .balanced, _ body: (inout JSONObjectAccumulator) throws -> Void) throws -> JSON { + .object(try JSONObject.build(limits: limits, body)) + } + + /// Admits the exact decimal meaning of one strict JSON number token. + /// + /// Delegates once to `JSONNumber.init(parsing:limits:)`; no binary floating + /// conversion occurs. The admitted original lexeme remains available. + /// - Parameters: + /// - text: A complete strict numeric token, such as `"0.1"`. + /// - limits: Finite numeric admission limits forwarded unchanged. + /// - Returns: The admitted number as a JSON value. + /// - Throws: Numeric syntax, resource or configuration failure, or cancellation. + public static func number(parsing text: String, limits: JSONLimits = .balanced) throws -> JSON { + .number(try JSONNumber(parsing: text, limits: limits)) + } + + /// Resolves a previously parsed RFC 6901 Pointer from this root. + /// + /// Delegates once to `JSONPointer.resolve(in:limits:)`. Pointer construction + /// remains a separate caller operation. Missing targets and wrong kinds throw. + /// - Parameters: + /// - pointer: A reusable Pointer with exact decoded tokens. + /// - limits: Finite admission and traversal limits forwarded unchanged. + /// - Returns: The selected value; an empty Pointer selects this root. + /// - Throws: Pointer, type, resource or configuration failure, or cancellation. + public func value(at pointer: JSONPointer, limits: JSONLimits = .balanced) throws -> JSON { + try pointer.resolve(in: self, limits: limits) + } +} + +extension JSONObject { + /// Tests exact scalar-key membership, including members whose value is null. + /// + /// Uses the existing unmetered indexed subscript. For bounded reads use + /// `JSON.read(limits:_:)` and cursor presence. Dynamic Strings require `JSONKey`. + /// - Parameter key: The exact key; string literals are accepted without normalization. + /// - Returns: Whether the member exists, independently of its value kind. + public func containsKey(_ key: JSONKey) -> Bool { self[key] != nil } +} diff --git a/Sources/JSON/Value/JSONPresence.swift b/Sources/JSON/Value/JSONPresence.swift index 71faf2c..497980d 100644 --- a/Sources/JSON/Value/JSONPresence.swift +++ b/Sources/JSON/Value/JSONPresence.swift @@ -12,6 +12,40 @@ extension JSONPresence: Sendable where Value: Sendable {} extension JSONPresence: Equatable where Value: Equatable {} extension JSONPresence: Hashable where Value: Hashable {} +extension JSONPresence { + /// Transforms a non-null payload while preserving absence and explicit null. + /// + /// The callback runs exactly once for `.value` and never for the other cases. + /// Its computation is caller work; this helper performs no bounded admission. + /// - Parameter transform: The synchronous payload transformation. + /// - Returns: The same presence state, with a transformed value when present. + /// - Throws: The callback's error unchanged. + public func map(_ transform: (Value) throws -> NewValue) rethrows -> JSONPresence { + switch self { + case .absent: return .absent + case .null: return .null + case .value(let payload): return .value(try transform(payload)) + } + } + + /// Selects exactly one callback for absent, null or non-null presence. + /// + /// Callback computation is caller work; no resource quota is implied. + /// - Parameters: + /// - absent: Invoked only when the field is missing. + /// - null: Invoked only for explicit JSON null. + /// - value: Invoked only with a non-null payload. + /// - Returns: The selected callback's result. + /// - Throws: The selected callback's error unchanged. + public func fold(absent: () throws -> Result, null: () throws -> Result, value: (Value) throws -> Result) rethrows -> Result { + switch self { + case .absent: return try absent() + case .null: return try null() + case .value(let payload): return try value(payload) + } + } +} + extension JSONObject { /// Looks up an exact scalar key and distinguishes absence, null and non-null values. /// diff --git a/Sources/JSONBridges/DecimalConversions.swift b/Sources/JSONBridges/DecimalConversions.swift new file mode 100644 index 0000000..d8c9819 --- /dev/null +++ b/Sources/JSONBridges/DecimalConversions.swift @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import JSON + +public extension Decimal { + /// Projects an exact JSON decimal into Foundation's finite-precision Decimal. + /// + /// Delegates once to `JSONNumber.decimalValue(policy:limits:)`. Exact policy + /// verifies mathematical equality; rounded policy still enforces target range + /// and precision admission. No reverse Decimal-to-JSON conversion is implied. + /// - Parameters: + /// - jsonNumber: The exact decimal value to project. + /// - policy: Require equality or allow the delegate's finite rounding policy. + /// - limits: Finite conversion limits forwarded unchanged. + /// - Throws: Precision/range failure, resource or configuration failure, or cancellation. + init(jsonNumber: JSONNumber, policy: JSONNumberConversionPolicy = .exact, limits: JSONLimits = .balanced) throws { + self = try jsonNumber.decimalValue(policy: policy, limits: limits) + } +} diff --git a/Sources/JSONBridges/Documentation.docc/JSONBridges.md b/Sources/JSONBridges/Documentation.docc/JSONBridges.md new file mode 100644 index 0000000..8472265 --- /dev/null +++ b/Sources/JSONBridges/Documentation.docc/JSONBridges.md @@ -0,0 +1,14 @@ +# ``JSONBridges`` + +Opt-in native conversions with explicit mathematical fidelity and finite admission. + +This module depends only on JSON. Import both modules explicitly; there is no +re-export, automatic core conformance or Foundation serialization authority. +All five methods delegate to existing JSONNumber operations. Original code is +Apache-2.0; no new upstream adaptation or external dependency is introduced. + +## Topics + +### Choosing a conversion + +- diff --git a/Sources/JSONBridges/Documentation.docc/NumericFidelity.md b/Sources/JSONBridges/Documentation.docc/NumericFidelity.md new file mode 100644 index 0000000..b107087 --- /dev/null +++ b/Sources/JSONBridges/Documentation.docc/NumericFidelity.md @@ -0,0 +1,46 @@ +# Numeric fidelity + +Import JSON and JSONBridges explicitly; import Foundation when using Decimal. +These opt-in development extensions do not appear with a core-only import. + +```swift +let count: UInt64 = 9_007_199_254_740_993 +let before = JSONNumber(integer: count) +let after = count.jsonNumber() // Trusted construction, without an operation meter. +precondition(before == after) +let recovered = try UInt64(jsonNumber: after) +precondition(recovered == count) +let tenth = try JSONNumber(parsing: "0.1") +let rounded = try Double(jsonNumber: tenth, policy: .rounded) +let adoptedText = try rounded.jsonNumberFromRoundTripText() +precondition(adoptedText == tenth) +let decimal = try Decimal(jsonNumber: tenth) +precondition(decimal == Decimal(string: "0.1")) +``` + +Trusted BinaryInteger construction is exact and unmetered, with work and storage +growing with digit count. It is not an untrusted arbitrary-precision admission API. +FixedWidthInteger projection is integral, sign- and range-checked and bounded by +the same finite limits as JSONNumber.integer(as:limits:). + +Decimal JSON 0.1 is mathematical one tenth. Binary64 0.1 is a different rational; +Double's shortest round-trip text is `0.1`. Adopting that text does not preserve +the exact binary rational. The default exact Double initializer rejects this +JSON decimal; `.rounded` explicitly permits finite rounding. NaN, infinities, +overflow, resource exhaustion and cancellation throw; none becomes JSON null. +Negative zero uses JSON's single mathematical zero identity. + +Do not widen Float to Double to simulate a Float bridge. On the qualified toolchain +Float(0.1) renders `0.1`, while its widened Double renders `0.10000000149011612`. +Float, Float16, Float80 and CGFloat source bridges are deferred. The package-only +exactBinary helper stays private: promoting exact-binary source conversion needs +a separate bounded-work, admission and retained-digit correctness review. + +Foundation Decimal has fixed precision and exponent bounds. Its initializer uses +the existing exact/rounded projection, including its limits. Decimal-to-JSON is +deferred pending a formatting, locale and admission contract. Arbitrary Any, +NSNumber, Optional and dictionary conversions are deliberately absent. + +Each throwing bridge forwards one existing delegate and limits unchanged. It +creates no cache or hidden multi-stage quota. Use the owning APIs for reports; +logical retained bytes are distinct from RSS and caller work from owned work. diff --git a/Sources/JSONBridges/DoubleConversions.swift b/Sources/JSONBridges/DoubleConversions.swift new file mode 100644 index 0000000..f6777f0 --- /dev/null +++ b/Sources/JSONBridges/DoubleConversions.swift @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON + +public extension Double { + /// Adopts the exact decimal meaning of this finite value's Swift round-trip text. + /// + /// Delegates once to `JSONNumber.fromFiniteDouble(_:limits:)`. For binary64 + /// 0.1 this produces decimal 0.1, which differs from the exact binary rational. + /// Negative zero has JSON's single mathematical zero identity. This method + /// does not recover an earlier token or promise bit-pattern preservation. + /// - Parameter limits: Finite decimal admission limits forwarded unchanged. + /// - Returns: The admitted decimal rendering of this finite Double. + /// - Throws: NaN/infinity rejection, resource or configuration failure, or cancellation. + func jsonNumberFromRoundTripText(limits: JSONLimits = .balanced) throws -> JSONNumber { + try JSONNumber.fromFiniteDouble(self, limits: limits) + } + + /// Projects an exact decimal into finite binary64 with an explicit fidelity policy. + /// + /// Delegates once to `JSONNumber.doubleValue(policy:limits:)`. The default + /// exact policy rejects decimal 0.1; rounded conversion requires caller opt-in. + /// - Parameters: + /// - jsonNumber: The exact decimal value to project. + /// - policy: Require mathematical equality or allow finite rounding. + /// - limits: Finite conversion limits forwarded unchanged. + /// - Throws: Inexact/overflowing conversion, resource or configuration failure, or cancellation. + init(jsonNumber: JSONNumber, policy: JSONNumberConversionPolicy = .exact, limits: JSONLimits = .balanced) throws { + self = try jsonNumber.doubleValue(policy: policy, limits: limits) + } +} diff --git a/Sources/JSONBridges/IntegerConversions.swift b/Sources/JSONBridges/IntegerConversions.swift new file mode 100644 index 0000000..4ee256d --- /dev/null +++ b/Sources/JSONBridges/IntegerConversions.swift @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON + +public extension BinaryInteger { + /// Constructs the exact decimal value of a trusted Swift integer. + /// + /// Delegates to `JSONNumber.init(integer:)` without an operation meter. + /// Conversion work and storage grow with the decimal digit count; arbitrary + /// user-defined integers are trusted input, not bounded untrusted admission. + /// - Returns: An exact integer with no original JSON token spelling. + func jsonNumber() -> JSONNumber { JSONNumber(integer: self) } +} + +public extension FixedWidthInteger { + /// Projects an exact decimal into this integer type with checked range and sign. + /// + /// Delegates once to `JSONNumber.integer(as:limits:)`; fractions and overflow + /// throw instead of truncating, defaulting or wrapping. + /// - Parameters: + /// - jsonNumber: The mathematical decimal to project. + /// - limits: Finite conversion limits forwarded unchanged. + /// - Throws: Inexact conversion, resource or configuration failure, or cancellation. + init(jsonNumber: JSONNumber, limits: JSONLimits = .balanced) throws { + self = try jsonNumber.integer(as: Self.self, limits: limits) + } +} diff --git a/Sources/JSONSchema/Compilation/CompiledJSONSchema.swift b/Sources/JSONSchema/Compilation/CompiledJSONSchema.swift index 82b69b7..7b1419d 100644 --- a/Sources/JSONSchema/Compilation/CompiledJSONSchema.swift +++ b/Sources/JSONSchema/Compilation/CompiledJSONSchema.swift @@ -10,6 +10,19 @@ public struct CompiledJSONSchema: Sendable { public let document: JSONSchemaDocument package let plan: JSONSchemaPlan package init(document: JSONSchemaDocument, plan: JSONSchemaPlan) { self.document = document; self.plan = plan } + /// Evaluates an instance using this immutable offline plan without recompiling it. + /// + /// Delegates to `JSONSchemaValidator.validate(_:against:)`; each call owns its + /// state and one cumulative evaluation budget. Use the validator's report API + /// for successful accounting. Compilation options are not evaluation defaults. + /// - Parameters: + /// - instance: The exact instance to admit and evaluate. + /// - options: Evaluation, diagnostic and format policies forwarded unchanged. + /// - Returns: A completed verdict; false means ordinary schema invalidity. + /// - Throws: Operational evaluation, resource or configuration failure, or cancellation. + public func validate(_ instance: JSON, options: JSONSchemaOptions = .init()) throws -> JSONValidationResult { + try JSONSchemaValidator(options: options).validate(instance, against: self) + } /// Inspects one raw object or boolean declaration, optionally at a document Pointer. /// /// - Returns: Immediate declarations; compiled inspection may add initial reference targets. diff --git a/Sources/JSONSchema/Documentation.docc/CompiledPlanConvenience.md b/Sources/JSONSchema/Documentation.docc/CompiledPlanConvenience.md new file mode 100644 index 0000000..772a2f9 --- /dev/null +++ b/Sources/JSONSchema/Documentation.docc/CompiledPlanConvenience.md @@ -0,0 +1,24 @@ +# Validate a compiled plan + +Compile explicit offline resources once. The convenience evaluates the immutable +snapshot with independent per-call state and evaluation options. + +```swift +let document = try JSONSchemaDocument(JSON.parse(#"{"type":"integer","minimum":0}"#)) +let plan = try JSONSchemaCompiler().compile(document) +let before = try JSONSchemaValidator().validate(42, against: plan) +let after = try plan.validate(42) +precondition(before.isValid && after.isValid) +let invalid = try plan.validate(-1) +precondition(!invalid.isValid) +let report = try JSONSchemaValidator().validateWithReport(42, against: plan) +precondition(report.result.isValid && report.statistics[.evaluationWorkUnits] > 0) +``` + +False is a completed invalid verdict. Evaluation errors, resource exhaustion and +cancellation throw. No compilation, online resolution, default insertion or +upgrade of unavailable diagnostics is hidden in validate. Options are evaluation +options; compilation settings do not become implicit evaluation defaults. +Use the validator's report API for cumulative successful accounting. After an +error or cancellation, a composition cannot recover unreported work by retrying +with a fresh quota. See for dialect and output policies. diff --git a/Sources/JSONSchema/Documentation.docc/JSONSchema.md b/Sources/JSONSchema/Documentation.docc/JSONSchema.md index 522e2b9..6c90134 100644 --- a/Sources/JSONSchema/Documentation.docc/JSONSchema.md +++ b/Sources/JSONSchema/Documentation.docc/JSONSchema.md @@ -64,6 +64,7 @@ Internal mutable meters remain private. ### Guides - +- ### Documents and plans - ``JSONSchemaDocument`` diff --git a/Tests/JSONBridgesTests/ConversionTests.swift b/Tests/JSONBridgesTests/ConversionTests.swift new file mode 100644 index 0000000..2d95e8b --- /dev/null +++ b/Tests/JSONBridgesTests/ConversionTests.swift @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Testing +import JSON +import JSONBridges + +private enum BridgeOutcome: Equatable { + case value(T), failure(JSONFailure), cancelled +} +private func outcome(_ body: () throws -> T) throws -> BridgeOutcome { + do { return .value(try body()) } + catch let error as JSONFailure { return .failure(error) } + catch is CancellationError { return .cancelled } +} + +@Test func e2TrustedIntegersRetainExactMathematicalValues() throws { + func check(_ values: [T]) throws { + for value in values { + let n = value.jsonNumber() + #expect(n == JSONNumber(integer: value)) + #expect(n.originalLexeme == nil) + #expect(try T(jsonNumber: n) == value) + } + } + try check([Int.min, -1, 0, 1, Int.max]) + try check([UInt64.min, 9_007_199_254_740_992, 9_007_199_254_740_993, UInt64.max]) + try check([Int8.min, -1, 0, Int8.max]); try check([UInt8.min, UInt8.max]) + try check([Int16.min, Int16.max]); try check([UInt16.min, UInt16.max]) + try check([Int32.min, Int32.max]); try check([UInt32.min, UInt32.max]) + #expect((9_007_199_254_740_993 as UInt64).jsonNumber() != (9_007_199_254_740_992 as UInt64).jsonNumber()) +} + +@Test func e2IntegerProjectionNeverTruncatesOrDefaults() throws { + for text in ["1.5", "-1", "256", "18446744073709551616", "1e1000000"] { + let n = try JSONNumber(parsing: text) + #expect(try outcome { try UInt8(jsonNumber: n) } == outcome { try n.integer(as: UInt8.self) }) + #expect(try outcome { try Int64(jsonNumber: n) } == outcome { try n.integer(as: Int64.self) }) + } + #expect(throws: JSONFailure.self) { try UInt64(jsonNumber: JSONNumber(parsing: "-1")) } + #expect(throws: JSONFailure.self) { try Int(jsonNumber: JSONNumber(parsing: "0.1")) } +} + +@Test func e2IntegerProjectionQuotaBoundaryMatchesDelegate() throws { + let n = UInt64.max.jsonNumber() + for metric in [JSONResourceMetric.numericWorkUnits, .workUnits] { + for cap in 0...32 { + var limits = JSONLimits.balanced; limits[metric] = UInt64(cap) + #expect(try outcome { try UInt64(jsonNumber: n, limits: limits) } == outcome { try n.integer(as: UInt64.self, limits: limits) }) + } + } + var limits = JSONLimits.balanced; limits[.numericWorkUnits] = 20 + #expect(try UInt64(jsonNumber: n, limits: limits) == UInt64.max) + limits[.numericWorkUnits] = 19 + #expect(throws: JSONFailure.self) { try UInt64(jsonNumber: n, limits: limits) } +} + +@Test func e2RoundTripTextIsDistinctFromExactBinaryValue() throws { + let decimalTenth = try JSONNumber(parsing: "0.1") + let adoptedText = try Double(0.1).jsonNumberFromRoundTripText() + #expect(adoptedText == decimalTenth) + #expect(throws: JSONFailure.self) { try Double(jsonNumber: adoptedText) } + #expect(try Double(jsonNumber: adoptedText, policy: .rounded) == 0.1) + #expect(String(Float(0.1)) == "0.1") + #expect(String(Double(Float(0.1))) != String(Float(0.1))) + #expect(try Double(Float(0.1)).jsonNumberFromRoundTripText() != adoptedText) +} + +@Test func e2RoundTripDoubleExtremesAndNonfiniteFailuresMatch() throws { + for d in [-0.0, 0.0, -1, 0.5, 0.1, Double.leastNonzeroMagnitude, .leastNormalMagnitude, .greatestFiniteMagnitude, .nan, .infinity, -.infinity] { + #expect(try outcome { try d.jsonNumberFromRoundTripText() } == outcome { try JSONNumber.fromFiniteDouble(d) }) + } + #expect(try (-0.0).jsonNumberFromRoundTripText().isZero) + #expect(try !(-0.0).jsonNumberFromRoundTripText().isNegative) +} + +@Test func e2DoubleProjectionExactRoundedAndOverflowMatch() throws { + for text in ["0", "0.5", "0.1", "9007199254740993", "1e1000000", "1e-1000000", "5e-324", "1.7976931348623157e308"] { + let n = try JSONNumber(parsing: text) + for policy in [JSONNumberConversionPolicy.exact, .rounded] { + #expect(try outcome { try Double(jsonNumber: n, policy: policy) } == outcome { try n.doubleValue(policy: policy) }) + } + } +} + +@Test func e2FloatingAndDecimalQuotasPreserveStructuredFailures() throws { + let n = try JSONNumber(parsing: "0.125") + for metric in [JSONResourceMetric.workUnits, .numericWorkUnits, .retainedBytes, .outputBytes] { + for cap in 0...150 { + var limits = JSONLimits.balanced; limits[metric] = UInt64(cap) + #expect(try outcome { try Double(jsonNumber: n, limits: limits) } == outcome { try n.doubleValue(limits: limits) }) + #expect(try outcome { try Decimal(jsonNumber: n, limits: limits) } == outcome { try n.decimalValue(limits: limits) }) + #expect(try outcome { try 0.125.jsonNumberFromRoundTripText(limits: limits) } == outcome { try JSONNumber.fromFiniteDouble(0.125, limits: limits) }) + } + } +} + +@Test func e2FoundationDecimalRangeAndPrecisionStayDelegated() throws { + for text in ["0", "0.1", "1e-128", "1e-129", "1e127", "1e165", "1e166", "123456789012345678901234567890123456789", "1234567890123456789012345678901234567890", "12345678901234567890123456789012345678901"] { + let n = try JSONNumber(parsing: text) + for policy in [JSONNumberConversionPolicy.exact, .rounded] { + #expect(try outcome { try Decimal(jsonNumber: n, policy: policy) } == outcome { try n.decimalValue(policy: policy) }) + } + } + #expect(try Decimal(jsonNumber: JSONNumber(parsing: "0.1")) == Decimal(string: "0.1")) +} + +@Test func e2BridgeCancellationRemainsAnError() async throws { + let n = try JSONNumber(parsing: "0.125") + let cancelled = await Task { + withUnsafeCurrentTask { $0?.cancel() } + #expect(throws: CancellationError.self) { try Int(jsonNumber: JSONNumber(integer: 1)) } + #expect(throws: CancellationError.self) { try Double(jsonNumber: n) } + #expect(throws: CancellationError.self) { try Decimal(jsonNumber: n) } + #expect(throws: CancellationError.self) { try 0.125.jsonNumberFromRoundTripText() } + // Trusted integer construction is intentionally unmetered, just like its delegate. + #expect(1.jsonNumber() == JSONNumber(integer: 1)) + return true + }.value + #expect(cancelled) +} + +@Test func e2SharedBridgeInputsHaveInvocationOwnedState() async throws { + let n = try JSONNumber(parsing: "0.125") + try await withThrowingTaskGroup(of: Bool.self) { group in + for _ in 0..<8 { + group.addTask { try Double(jsonNumber: n) == 0.125 && Decimal(jsonNumber: n) == Decimal(string: "0.125") } + } + for try await result in group { #expect(result) } + } +} diff --git a/Tests/JSONSchemaTests/ErgonomicsTests.swift b/Tests/JSONSchemaTests/ErgonomicsTests.swift new file mode 100644 index 0000000..794e041 --- /dev/null +++ b/Tests/JSONSchemaTests/ErgonomicsTests.swift @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +import Testing +import JSON +import JSONSchema + +private func sameVerdict(_ a: JSONValidationResult, _ b: JSONValidationResult) throws { + #expect(a.isValid == b.isValid && a.diagnosticsComplete == b.diagnosticsComplete) + #expect(a.annotations.map(\.value) == b.annotations.map(\.value)) + #expect(a.annotations.map(\.instanceLocation) == b.annotations.map(\.instanceLocation)) + #expect(a.annotations.map(\.keywordLocation) == b.annotations.map(\.keywordLocation)) + #expect(try a.output() == b.output()) +} + +@Test func e2PlanValidationPreservesVerdictAnnotationsAndFormatPolicy() throws { + let plan = try JSONSchemaCompiler().compile(JSONSchemaDocument(JSON.parse(#"{"type":"string","format":"email","title":"address","minLength":2}"#))) + for options in [JSONSchemaOptions(), .init(formatAssertion: true), .init(output: .flag), .init(collectAnnotations: true)] { + for instance in [JSON.string("a"), "a@b.example", 42] { + try sameVerdict(plan.validate(instance, options: options), JSONSchemaValidator(options: options).validate(instance, against: plan)) + } + } + #expect(try !plan.validate(42).isValid) + #expect(try plan.validate("ab").isValid) +} + +@Test func e2PlanValidationQuotasAndIncompleteDiagnosticsMatch() throws { + let plan = try JSONSchemaCompiler().compile(JSONSchemaDocument(JSON.parse(#"{"type":"object","required":["a","b"]}"#))) + let instance = try JSON.parse("{}") + for cap in 0...100 { + var limits = JSONLimits.balanced; limits[.evaluationWorkUnits] = UInt64(cap) + let options = JSONSchemaOptions(limits: limits) + func failure(_ facade: Bool) throws -> JSONFailure? { + do { + let result = try facade ? plan.validate(instance, options: options) : JSONSchemaValidator(options: options).validate(instance, against: plan) + #expect(!result.isValid) + return nil + } catch let error as JSONFailure { return error } + } + #expect(try failure(true) == failure(false)) + } + var limits = JSONLimits.balanced; limits[.issues] = 0 + let options = JSONSchemaOptions(limits: limits, diagnosticPolicy: .bestEffort) + let result = try plan.validate(instance, options: options) + #expect(!result.isValid && !result.diagnosticsComplete) + try sameVerdict(result, JSONSchemaValidator(options: options).validate(instance, against: plan)) + #expect(throws: JSONFailure.self) { try plan.validate(instance, options: .init(limits: limits)) } +} + +@Test func e2PlanOperationalFailureAndCancellationNeverBecomeFalse() async throws { + let cycle = try JSONSchemaCompiler().compileWithReport(JSON.parse(##"{"anyOf":[true,{"$ref":"#"}]}"##)).result + #expect(throws: JSONSchemaEvaluationFailure.self) { try cycle.validate(.null) } + #expect(throws: JSONSchemaEvaluationFailure.self) { try JSONSchemaValidator().validate(.null, against: cycle) } + let plan = try JSONSchemaCompiler().compile(JSONSchemaDocument(true)) + let cancelled = await Task { + withUnsafeCurrentTask { $0?.cancel() } + #expect(throws: CancellationError.self) { try plan.validate(.null) } + #expect(throws: CancellationError.self) { try JSONSchemaValidator().validate(.null, against: plan) } + return true + }.value + #expect(cancelled) +} + +@Test func e2SharedCompiledPlanFacadeHasIndependentInvocationState() async throws { + let plan = try JSONSchemaCompiler().compile(JSONSchemaDocument(JSON.parse(#"{"type":"string","pattern":"^a+$"}"#))) + try await withThrowingTaskGroup(of: Bool.self) { group in + for index in 0..<12 { + group.addTask { + let value: JSON = index.isMultiple(of: 2) ? "aaa" : "bbb" + return try plan.validate(value).isValid == index.isMultiple(of: 2) + } + } + for try await result in group { #expect(result) } + } +} diff --git a/Tests/JSONTests/ErgonomicsTests.swift b/Tests/JSONTests/ErgonomicsTests.swift new file mode 100644 index 0000000..9bc8347 --- /dev/null +++ b/Tests/JSONTests/ErgonomicsTests.swift @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Testing +import JSON + +private enum E2Outcome: Equatable { + case value(T), failure(JSONFailure), cancelled +} +private func outcome(_ body: () throws -> T) throws -> E2Outcome { + do { return .value(try body()) } + catch let error as JSONFailure { return .failure(error) } + catch is CancellationError { return .cancelled } +} + +@Test func e2ParseDelegatesEveryInputKindAndPreservesLexemes() throws { + for text in [#"{"n":9007199254740993,"é":1,"e\u0301":2,"z":-0.00e2}"#, "[null,true,1.0,1e0]", " \n42\t"] { + let parser = JSONParser() + let a = try JSON.parse(text), b = try parser.parse(text) + #expect(a == b) + #expect(try JSON.parse(Data(text.utf8)) == parser.parse(Data(text.utf8))) + #expect(try JSON.parse(Array(text.utf8)) == parser.parse(Array(text.utf8))) + let writer = JSONWriter(options: .init(numberStyle: .preserved)) + #expect(try writer.write(a) == writer.write(b)) + } +} + +@Test func e2ParseFailuresKeepLocationsAndInputKindAccounting() throws { + let vectors: [[UInt8]] = [[], [0x22, 0xff, 0x22], Array("[1,]".utf8), Array("true false".utf8), Array(#"{"a":1,"\u0061":2}"#.utf8), [0xef,0xbb,0xbf,0x30]] + for bytes in vectors { + #expect(try outcome { try JSON.parse(bytes) } == outcome { try JSONParser().parse(bytes) }) + #expect(try outcome { try JSON.parse(Data(bytes)) } == outcome { try JSONParser().parse(Data(bytes)) }) + } + for text in ["", "1 2", #"{"a":0,"a":1}"#, #""\uD800""#] { + #expect(try outcome { try JSON.parse(text) } == outcome { try JSONParser().parse(text) }) + } +} + +@Test func e2ParseInclusiveQuotasMatchEachDelegate() throws { + let text = #"{"n":1}"#, bytes = Array(text.utf8), data = Data(text.utf8) + for metric in [JSONResourceMetric.inputBytes, .workUnits, .retainedBytes, .nodes, .keyBytes] { + for cap in 0...160 { + var limits = JSONLimits.balanced; limits[metric] = UInt64(cap) + let options = JSONParsingOptions(limits: limits), parser = JSONParser(options: options) + #expect(try outcome { try JSON.parse(text, options: options) } == outcome { try parser.parse(text) }) + #expect(try outcome { try JSON.parse(data, options: options) } == outcome { try parser.parse(data) }) + #expect(try outcome { try JSON.parse(bytes, options: options) } == outcome { try parser.parse(bytes) }) + } + } + var limits = JSONLimits.balanced; limits[.inputBytes] = UInt64(bytes.count) + #expect(try JSON.parse(bytes, options: .init(limits: limits)) == JSONParser().parse(bytes)) + limits[.inputBytes] -= 1 + #expect(throws: JSONFailure.self) { try JSON.parse(bytes, options: .init(limits: limits)) } +} + +@Test func e2ObjectBuilderUsesExistingOrderAndDuplicateRules() throws { + let body: (inout JSONObjectAccumulator) throws -> Void = { + try $0.append("é", value: 1); try $0.append("e\u{301}", value: 2); try $0.append("null", value: .null) + } + let a = try JSON.buildObject(body), b = JSON.object(try JSONObject.build(body)) + #expect(a == b) + #expect(try JSONWriter().write(a) == JSONWriter().write(b)) + let duplicate: (inout JSONObjectAccumulator) throws -> Void = { + try $0.append("a", value: 1); try $0.append("a", value: 2) + } + #expect(try outcome { try JSON.buildObject(duplicate) } == outcome { .object(try JSONObject.build(duplicate)) }) +} + +@Test func e2ObjectBuilderQuotasRemainCumulative() throws { + let body: (inout JSONObjectAccumulator) throws -> Void = { try $0.append("a", value: 1); try $0.append("b", value: 2) } + for cap in 0...180 { + var limits = JSONLimits.balanced; limits[.workUnits] = UInt64(cap) + #expect(try outcome { try JSON.buildObject(limits: limits, body) } == outcome { .object(try JSONObject.build(limits: limits, body)) }) + } + var limits = JSONLimits.balanced; limits[.objectMembers] = 1 + #expect(throws: JSONFailure.self) { try JSON.buildObject(limits: limits, body) } +} + +@Test func e2NumberTextPreservesExactIdentityAndFailures() throws { + for text in ["1", "1.0", "1e0", "-0.0", "9007199254740993", "0.1", "1e1000000", "", "01", "1e", "NaN"] { + #expect(try outcome { try JSON.number(parsing: text) } == outcome { .number(try JSONNumber(parsing: text)) }) + if let number = try? JSONNumber(parsing: text) { + #expect(try JSON.number(parsing: text).numberValue?.originalLexeme == number.originalLexeme) + } + } + for cap in 0...80 { + var limits = JSONLimits.balanced; limits[.numericWorkUnits] = UInt64(cap) + #expect(try outcome { try JSON.number(parsing: "123.00", limits: limits) } == outcome { .number(try JSONNumber(parsing: "123.00", limits: limits)) }) + } +} + +@Test func e2PointerRootOrientationRetainsStrictTraversal() throws { + let value = try JSON.parse(#"{"a/b":{"~":[null,7]},"":9}"#) + for path in ["", "/", "/a~1b/~0/1", "/a~1b/~0/0", "/missing", "/a~1b/~0/01", "/a~1b/~0/-", "/a~1b/~0/1/x"] { + let pointer = try JSONPointer(parsing: path) + #expect(try outcome { try value.value(at: pointer) } == outcome { try pointer.resolve(in: value) }) + for cap in 0...100 { + var limits = JSONLimits.balanced; limits[.workUnits] = UInt64(cap) + #expect(try outcome { try value.value(at: pointer, limits: limits) } == outcome { try pointer.resolve(in: value, limits: limits) }) + } + } +} + +@Test func e2ContainsKeySeparatesNullMissingAndScalarSpellings() throws { + let object = try JSONObject(members: [JSONMember("é", value: .null), JSONMember("e\u{301}", value: 2)]) + #expect(object.containsKey("é")); #expect(object.containsKey("e\u{301}")); #expect(!object.containsKey("missing")) + let dynamic = "é" + #expect(object.containsKey(JSONKey(dynamic)) == (object[dynamic] != nil)) + let changed = try object.setting(3, for: "é").removing("e\u{301}").setting(4, for: "e\u{301}") + #expect(changed.members.map(\.key) == object.members.map(\.key)) + #expect(throws: JSONFailure(.typeMismatch, code: .wrongKind)) { try JSON.boolean(false).presence(of: "é") } +} + +@Test func e2PresenceMapAndFoldSelectExactlyOneBranch() throws { + for state in [JSONPresence.absent, .null, .value(3)] { + var calls = 0 + let mapped = state.map { calls += 1; return String($0) } + var branches: [String] = [] + let result = mapped.fold(absent: { branches.append("absent"); return "missing" }, null: { branches.append("null"); return "null" }, value: { branches.append("value"); return $0 }) + switch state { + case .absent: #expect(calls == 0 && mapped == .absent && result == "missing" && branches == ["absent"]) + case .null: #expect(calls == 0 && mapped == .null && result == "null" && branches == ["null"]) + case .value: #expect(calls == 1 && mapped == .value("3") && result == "3" && branches == ["value"]) + } + let set: Set> = [state] + #expect(set.contains(state)) + } +} + +private enum E2CallbackError: Error, Equatable { case sentinel } +@Test func e2CallerErrorsPropagateWithoutFallback() { + #expect(throws: E2CallbackError.sentinel) { try JSON.buildObject { _ in throw E2CallbackError.sentinel } } + #expect(throws: E2CallbackError.sentinel) { try JSONPresence.value(1).map { _ -> Int in throw E2CallbackError.sentinel } } + for state in [JSONPresence.absent, .null, .value(1)] { + #expect(throws: E2CallbackError.sentinel) { + try state.fold(absent: { throw E2CallbackError.sentinel }, null: { throw E2CallbackError.sentinel }, value: { _ in throw E2CallbackError.sentinel }) as Int + } + } +} + +@Test func e2OldLiteralAndFactoryCallsitesKeepTheirTypes() throws { + let a: JSON = .number(42), b = JSON.number(JSONNumber(integer: 42)) + let factory: (JSONNumber) -> JSON = JSON.number + #expect(a == b && factory(42) == a) + #expect([JSONNumber(integer: 42)].map(JSON.number) == [a]) + if case .number(let n) = a { #expect(n == 42) } else { Issue.record("Enum pattern changed") } + struct Model: Codable, Equatable { let n: Int } + #expect(try JSON(encoding: Model(n: 42)).decode(Model.self) == Model(n: 42)) +} + +@Test func e2CancellationPropagatesThroughCoreFacades() async throws { + let pointer = try JSONPointer(parsing: "") + let cancelled = await Task { + withUnsafeCurrentTask { $0?.cancel() } + let operations: [() throws -> JSON] = [ + { try JSON.parse("0") }, { try JSON.parse(Data([48])) }, { try JSON.parse([48]) }, + { try JSON.number(parsing: "0") }, { try JSON.buildObject { _ in } }, { try JSON.null.value(at: pointer) } + ] + for operation in operations { #expect(throws: CancellationError.self, performing: operation) } + return true + }.value + #expect(cancelled) + let during = await Task { + do { + _ = try JSON.buildObject { object in + try object.append("before", value: 1) + withUnsafeCurrentTask { $0?.cancel() } + try object.append("after", value: 2) + } + return false + } catch is CancellationError { return true } catch { return false } + }.value + #expect(during) +}