diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 5c234b4..a7b9e86 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -3,17 +3,20 @@ name: Checks on: push: branches: [main] + pull_request: + types: [labeled] workflow_dispatch: permissions: contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.ref }}-${{ github.event.label.name }} cancel-in-progress: true jobs: tests: + if: github.event_name != 'pull_request' || github.event.label.name == 'run-ci' name: ${{ matrix.os }} / ${{ matrix.configuration }} runs-on: ${{ matrix.os }} container: ${{ matrix.container }} @@ -80,3 +83,43 @@ jobs: path: Enigmatic.doccarchive.tar.gz compression-level: 0 if-no-files-found: error + + windows: + if: github.event_name != 'pull_request' || github.event.label.name == 'run-ci' + name: windows-2022 / ${{ matrix.configuration }} + runs-on: windows-2022 + strategy: + fail-fast: false + matrix: + configuration: [debug, release] + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v6 + - name: Install Swift 6.4.1 + uses: compnerd/gha-setup-swift@86d1ddeaf915c282137601aeb0654ea0e7b7f17e + with: + swift-version: swift-6.4.1-release + swift-build: 6.4.1-RELEASE + - name: Verify Swift 6.4.1 for Windows x64 + run: | + $version = (swift --version) -join "`n" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Output $version + if ($version -notmatch 'Swift version 6\.4\.1(\s|$)') { + throw 'Expected Swift 6.4.1' + } + if ($version -notmatch 'Target: x86_64-unknown-windows-msvc') { + throw 'Expected the Windows x64 toolchain' + } + - name: Functional tests + run: | + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + $vsPath = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vsPath) { throw 'Visual Studio C++ tools not found' } + Import-Module (Join-Path $vsPath 'Common7\Tools\Microsoft.VisualStudio.DevShell.dll') + Enter-VsDevShell -VsInstallPath $vsPath -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -host_arch=x64' + # Work around Swift 6.4.1's build-system and debug-type assertion issues. + swift test -c ${{ matrix.configuration }} --build-system native -Xswiftc -Xfrontend -Xswiftc -disable-round-trip-debug-types --skip PerformanceTests + exit $LASTEXITCODE diff --git a/README.md b/README.md index 4c68668..eb46e7c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![License](https://img.shields.io/github/license/SwiftUtility/Enigmatic)](LICENSE) [![Coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftutility.github.io%2FEnigmatic%2Fcoverage-badge.json)](https://github.com/SwiftUtility/Enigmatic/actions/workflows/release.yml) [![Documentation](https://img.shields.io/badge/Documentation-DocC-blue)](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/) -[![Tested platforms](https://img.shields.io/badge/tested_on-macOS%20%7C%20Linux-blue)](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/development/) +[![Tested platforms](https://img.shields.io/badge/tested_on-macOS%20%7C%20Linux%20%7C%20Windows-blue)](https://swiftutility.github.io/Enigmatic/documentation/enigmatic/development/) Turn `Codable` values into a tree you can inspect, edit, merge, and decode again. Use `@Codec.Box` to choose how individual properties are encoded. diff --git a/Sources/Enigmatic/Enigmatic.docc/Development.md b/Sources/Enigmatic/Enigmatic.docc/Development.md index 9538ca9..7b3c70f 100644 --- a/Sources/Enigmatic/Enigmatic.docc/Development.md +++ b/Sources/Enigmatic/Enigmatic.docc/Development.md @@ -7,13 +7,43 @@ swift test --skip PerformanceTests swift test -c release --skip PerformanceTests ``` -CI uses Swift 6.3.3 from Xcode 26.6 on macOS 26 and the `swift:6.3.3` -container on Ubuntu 24.04. Each job verifies the compiler version before testing. +On Windows, use Developer PowerShell for Visual Studio with the C++ tools and +Windows SDK installed. Start a fresh shell after installing Swift so `PATH` and +`SDKROOT` include the toolchain and its SDK. + +The functional suite passed locally in Debug and Release on Windows x64 with +Swift 6.4.1 on 2026-10-16. + +Local Windows x64 validation with Swift 6.4.1 encountered two toolchain issues: +the default build system failed to find `stdlib.h`, and the compiler's debug-type +round-trip check asserted on negative integer generic arguments in the strategy +tests. To reproduce the successful validation, use: + +```powershell +$windowsSwiftFlags = @( + '--build-system', 'native', + '-Xswiftc', '-Xfrontend', '-Xswiftc', '-disable-round-trip-debug-types' +) +swift test @windowsSwiftFlags --skip PerformanceTests +swift test @windowsSwiftFlags -c release --skip PerformanceTests +``` + +These are toolchain workarounds, not package settings. The native build system is +deprecated; recheck these workarounds when updating Swift. Windows validation +does not replace the macOS and Linux CI jobs below. + +The macOS and Linux CI jobs use Swift 6.3.3 from Xcode 26.6 on macOS 26 and the +`swift:6.3.3` container on Ubuntu 24.04. Each job verifies the compiler version +before testing. The macOS Debug job also checks the coverage script, enforces 95% library line coverage, and builds DocC. It uploads the coverage report as `coverage.json` and the documentation as `Enigmatic.doccarchive.tar.gz`, preserving DocC filenames inside the archive. +Checks also runs separate Windows x64 Debug and Release jobs on `windows-2022` +with Swift 6.4.1, Visual Studio's C++ environment, and the workarounds above. +These jobs do not change the macOS/Linux checks or the Release workflow. + The Release workflow publishes the DocC site to GitHub Pages after all release validation jobs pass and before creating the GitHub release. The site is at . Enable @@ -26,9 +56,14 @@ It reports actual library line coverage from the last successful release documentation deployment. Documentation and coverage links become available after the first successful Pages deployment. -CI runs automatically for pushes to `main`. To check a pull request, start -**Actions → Checks → Run workflow** and select its source branch. Pull requests -and pushes to other branches do not start CI automatically. +CI runs automatically for pushes to `main`. To check a pull request, add the +`run-ci` label from the PR sidebar. Results appear in the PR's **Checks** tab. +Opening a PR or pushing new commits to it does not start CI automatically, +even while the label remains attached. Remove and reapply `run-ci` to check the +latest changes. Other labels do not run tests or cancel an active `run-ci` run. + +You can also start **Actions → Checks → Run workflow** and select a branch. +These manual branch runs do not appear as PR checks. ```sh python3 -m unittest discover -s Scripts/tests diff --git a/Sources/Enigmatic/Enigmatic.docc/Serialization.md b/Sources/Enigmatic/Enigmatic.docc/Serialization.md index 6303357..ebc9f1b 100644 --- a/Sources/Enigmatic/Enigmatic.docc/Serialization.md +++ b/Sources/Enigmatic/Enigmatic.docc/Serialization.md @@ -46,6 +46,9 @@ require keys that bridge to String. Keys normalize to strings; collisions and unsupported key types produce a decoding error. Arrays and sets convert to arrays, with set order unspecified. +Boxed Foundation booleans remain boolean nodes, distinct from numeric zero and +one, including on Windows where CoreFoundation is not exposed by the SDK. + On all platforms, `Enigma(cast:)` converts `NSDecimalNumber` values by parsing their `stringValue` with `Double.init(_:)`, rather than reading `doubleValue`. This avoids precision loss in Foundation's decimal-to-Double diff --git a/Sources/Enigmatic/Internal/Extensions/Enigma+Utility.swift b/Sources/Enigmatic/Internal/Extensions/Enigma+Utility.swift index 86f80be..34ab829 100644 --- a/Sources/Enigmatic/Internal/Extensions/Enigma+Utility.swift +++ b/Sources/Enigmatic/Internal/Extensions/Enigma+Utility.swift @@ -1,5 +1,4 @@ import Foundation -import CoreFoundation extension Enigma { func collectPins( diff --git a/Sources/Enigmatic/Internal/Primitives/EnigmaConvertibleObject.swift b/Sources/Enigmatic/Internal/Primitives/EnigmaConvertibleObject.swift index 690e88d..977efdf 100644 --- a/Sources/Enigmatic/Internal/Primitives/EnigmaConvertibleObject.swift +++ b/Sources/Enigmatic/Internal/Primitives/EnigmaConvertibleObject.swift @@ -1,5 +1,7 @@ -import CoreFoundation import Foundation +#if canImport(CoreFoundation) +import CoreFoundation +#endif protocol EnigmaConvertibleObject: EnigmaConvertible, AnyObject {} @@ -52,7 +54,12 @@ extension NSValue: EnigmaConvertibleObject { extension NSNumber { var asEnigma: Enigma? { +#if canImport(CoreFoundation) guard CFGetTypeID(self) != CFBooleanGetTypeID() else { return .bool(boolValue) } +#else + // Foundation's boolean singletons distinguish Bool from numeric zero and one. + if self === NSNumber(value: true) || self === NSNumber(value: false) { return .bool(boolValue) } +#endif return switch objCType.pointee { case ObjCType.signedLong: .int(intValue) case ObjCType.unsignedLong: .uint(uintValue) diff --git a/Tests/EnigmaticTests/PerformanceTests.swift b/Tests/EnigmaticTests/PerformanceTests.swift index df8c834..c0f7745 100644 --- a/Tests/EnigmaticTests/PerformanceTests.swift +++ b/Tests/EnigmaticTests/PerformanceTests.swift @@ -1,6 +1,5 @@ @testable import Enigmatic import Foundation -import CoreFoundation import XCTest final class PerformanceTests: XCTestCase { diff --git a/Tests/EnigmaticTests/SerializationContractTests.swift b/Tests/EnigmaticTests/SerializationContractTests.swift index 5ef04b2..dac2ad2 100644 --- a/Tests/EnigmaticTests/SerializationContractTests.swift +++ b/Tests/EnigmaticTests/SerializationContractTests.swift @@ -161,6 +161,29 @@ final class SerializationContractTests: XCTestCase { XCTAssertEqual(try Enigma(cast: NSNull()), .null) } + func testFoundationBooleansRemainDistinctFromZeroAndOne() throws { + for value in [false, true] { + let tree = try Enigma(cast: NSNumber(value: value)) + XCTAssertEqual(tree.asBool, value) + XCTAssertNil(tree.asInt) + } + for value in [0, 1] { + for number in [NSNumber(value: value), NSNumber(value: Int8(value)), NSNumber(value: Double(value))] { + let tree = try Enigma(cast: number) + XCTAssertNil(tree.asBool) + XCTAssertEqual(tree.asInt, value) + } + } + let tree: Enigma = [false, true, 0, 1] + let data = try JSONSerialization.data(withJSONObject: tree.asJsonObject) + let restored = try Enigma(cast: JSONSerialization.jsonObject(with: data)) + XCTAssertEqual(restored[0]?.asBool, false) + XCTAssertEqual(restored[1]?.asBool, true) + XCTAssertNil(restored[2]?.asBool) + XCTAssertNil(restored[3]?.asBool) + XCTAssertEqual(restored, tree) + } + func testFloatFoundationSerializationRoundTrips() throws { let values: [Float] = [ 0.1,