Fuaranfuaran

The source for generative UI

The Swift render projection

fuaran-swift is the native Swift surface of the Fuaran UI wire format, over the Rust reference core. The core owns truth and mutation — decode, apply tree-ops, render — behind a C-ABI; the Swift side decodes the core's canonical tree JSON into native sealed types for rendering.

That division decides what this package does and does not owe you, so it is worth being exact about before anything else.

What this is, and what it is not

This is a render projection, not a conformant host. The wire format's own host roster classifies it that way, and draws the consequence: a render projection consumes a codec host's already-decoded tree for native rendering only. It never canonically encodes, so it carries no byte-parity leg. Its bar is a render-coverage checklist over the node corpus — a new NodeKind lacking a renderer arm is a build error in this tier, but is not a wire conformance failure.

So the claim this package makes is: every node fixture in the shared conformance corpus decodes into the sealed model, with a compile-time-exhaustive switch. A byte-parity claim would be a claim about the core.

Three things follow, and each is visible in the code rather than merely asserted:

  • JSON.object is an unordered dictionary. A canonical codec could not model it that way — canonicalisation sorts object keys and reproduces number form byte-exactly. The read-only model is structural proof that no encode leg is possible from it.
  • There is no TreeOp decoder at all. The core owns apply, and a render projection never sees an op.
  • There is no pre-emit validator. A pre-emit validator is a check an emitter runs before it emits, and this surface emits no tree.

One function will look like a counter-example and is not. Interaction.swift's internal jsonToWire serialises a scalar so a state write-back can hand a value back to the session. It is not a canonical tree encoder, and the only thing that ever travels Swift → core is a $state value; the core owns the canonical encode of the tree that results.

Package shape

Swift 6 tools version, macOS 13 / iOS 16, and zero external package dependencies — the only imports across Sources/ are Foundation, SwiftUI, Combine, and the local C target.

ProductWhat it is
FuaranUIThe sealed tree model, the render-projection decoder, the URL policy, and FuaranSession
FuaranUIRendererThe SwiftUI floor, the tone bridge, and the pure render plans
FuaranUIDriverThe server-driven loop. Foundation only, so it builds everywhere

The native core is linked conditionally, and that is load-bearing

There is no systemLibrary target and no build plugin. Package.swift probes the filesystem for the Rust static library — at FUARAN_RS_STATICLIB_DIR if set, otherwise the sibling ../fuaran-rs/target/debug — and only when it finds one does it add the FuaranCore C target, wire the linker flags, and define FUARAN_CORE_AVAILABLE.

So a checkout without the core still builds and tests the pure-Swift projection, and the session tests skip cleanly. The whole of Session.swift sits behind that flag; on a machine without the core, the file compiles to nothing and the decoder stands alone.

On every platform the core reaches this package the same way today: the Rust staticlib, built beside this repository and linked directly by Package.swift (see the README's "Consuming a live session"). There is no FuaranCore.xcframework yet; when one is packaged it lands with a binaryTarget and a CI job that fails unless the artefact is produced, in the same change.

Decoding a tree

One public entry point:

import FuaranUI

let root = try RenderProjection.decodeNode(json)   // canonical tree JSON from a session
public struct Node: Equatable, Sendable {
  public var id: String
  public var kind: NodeKind
  public var state: StateBehaviour
  public var style: SemanticStyle
  public var accessibility: Accessibility?
}

NodeKind is an indirect enum with one case per wire $type, so a switch over it is exhaustive at compile time:

guard case .box(let box) = root.kind else { return }   // box.role == .dashboard
for child in box.children {
    switch child.kind {
    case .heading(let h): print(h.level, h.text)   // 2, .literal("Channel performance")
    case .markdown(let m): print(m.text)
    default: break
    }
}

In your own exhaustive walks, leave off the default. A new wire kind then arrives as a build error naming your file — which is the guarantee the sealed model exists to give. The package keeps three such tripwires deliberately: NodeKind.typeName and NodeKind.category in the model itself, and fuaranCoverageChildren in the renderer.

Three Swift names differ from their wire spelling, because the obvious ones are taken or reserved: the cases are switchKind ("Switch"), dataGrid ("DataGrid"), and filters — the only case whose payload is a bare array ([FilterSpec]) rather than a spec record.

The closed bare-string vocabularies (ToneVariant, BoxRole, ChartKind, and some forty others) are String-raw enums whose rawValue is the wire spelling, so init?(rawValue:) is the parse and a missing case is a throwable unknown discriminator rather than a silent fallback.

Failure is typed, and the path convention has a subtlety

public struct FuaranDecodeError: Error, Equatable {
  public enum Code: String {
    case invalidJson   = "INVALID_JSON"
    case missingField  = "MISSING_FIELD"
    case wrongType     = "WRONG_TYPE"
    case unknownDuCase = "UNKNOWN_DU_CASE"
    case emptyNodeId   = "EMPTY_NODE_ID"
    case wrongNodeKind = "WRONG_NODE_KIND"
    case limitExceeded = "LIMIT_EXCEEDED"
  }
  public let code: Code
  public let path: String      // "$"-rooted, e.g. "$.kind.level"
  public let message: String
}

LIMIT_EXCEEDED is deliberately not INVALID_JSON: the input is well-formed JSON, refused for being structurally unbounded, and calling a well-formed-but-too-deep document malformed is an actively wrong diagnosis. The message names the limit and the observed shape so an author knows which bound to come back under.

The $type suffix appears in a path only when a discriminator is genuinely at fault. An unrecognised case of a $type-tagged union reports at $.kind.$type; an unrecognised case of a bare enum reports at the field's own path — $.style.tone, with no suffix. There is no $type member in the document at a bare-enum position, so naming one would send an author to edit a key that is not there.

The limits are per call, not global

public enum WireLimits {
  public static let maxNodeDepth   = 24
  public static let maxJSONDepth   = 256
  public static let maxStringLength = 1_048_576
  public static let maxArrayLength  = 100_000
  public static let maxNodes        = 100_000
}

The counters are created per decodeNode call and threaded down the walk rather than held in global or thread-local state. decodeNode is public API, so concurrent decode from several tasks is expected usage, and shared mutable counters would be a data race of the damaging kind: two decodes silently mis-bounding each other shows up as a valid tree refused on a busy machine and never on a quiet one. Under Swift 6 strict concurrency it would not compile without an unsafe escape hatch, and reaching for one to bound untrusted input would be an odd trade.

The compiler is the checklist: a decoder function that reaches node without carrying the state is a build error.

Lenient where the specification says to be, strict where it matters

This is a consumer-grade decoder — being stricter than the language is an availability defect, not a safe default. Four enumerated leniencies:

  • A Static envelope wrapped around a plain scalar unwraps before every scalar read, in one place rather than site by site. An object that is not a well-formed Static envelope passes through and fails normally.
  • Curated enum alias tables. "Row"/"row" read as .horizontal on the CSS flex-direction prior; "Positive" as .success; "Danger"/"Negative" as .critical. Anything else still fails UNKNOWN_DU_CASE naming the canonical list.
  • Field-name aliases, with the canonical name always winning when both are present, and the nested path always using the canonical name.
  • Five legacy node-kind upgrades — Dashboard, Stack, GridLayout, Card fold into Box.

And two places it is deliberately strict. An explicit null in a host-opaque payload slot is refused: the wire spells absence by omitting the key, so a null is a malformed document, and accepting it would hand the app a slot that claims to carry a value and does not. And a typed binding slot still checks its type — the shorthand rules are about shape, so "label": "Home" is sanctioned while "hidden": "yes" is refused.

What the projection does not model

motion and extraAttributes are wire-omitted by design and absent from the model. Closure-bearing slots carry a Closure marker — presence only, since the value is unobservable. And TrendPolarity.Neutral is reserved by the specification and deliberately not a case: the case set is the accepted wire set, so "Neutral" fails rather than this surface quietly deciding a question the reservation holds open. No alias arm is registered for it, because the obvious candidates are precisely the spellings that must not be accepted.

Two modelling decisions are worth knowing because they read as omissions and are not. An image's fit / aspectRatio / loading are total, not optional — "absent means Natural" is a default rather than a third state, and modelling absence as nil would push the decision out to every reader. And srcSet is [SrcSetEntry], not [SrcSetEntry]?, because an absent slot and an empty one denote the same document; a surface answering nil for an absent srcSet has produced a value the format's own encoder cannot round-trip.

The safety floor

A decoded tree is untrusted input, and usually arrives from a model.

switch link.sanitizedHref {                       // NOT link.href
case .allowed(let url):     open(URL(string: url)!)
case .rejected(_, let why): log("refused destination: \(why)")
case .dynamic:                                    // a State / Query / Format binding
    if let safe = FuaranUrlPolicy.sanitize(resolvedHref) { open(URL(string: safe)!) }
}

Accessors exist for every slot the wire hands over verbatim: LinkSpec.sanitizedHref, ImageSpec.sanitizedSrc, each SrcSetEntry.sanitizedSrc, MediaSpec.sanitizedSrc, a video's sanitizedPoster, and Action.sanitizedNavigateRoute.

The floor is a public accessor rather than a decode-time filter, and the reason is a real consequence of the architecture: href and src are Bindings whose value may not exist until the core resolves a State, Query or Format slot, so a check at decode time would be examining a placeholder. The projection stays a faithful view of the wire; the check happens where a real destination exists. .dynamic is a case rather than a nil for the same reason — "refused" and "not knowable yet" call for different handling.

The full policy, and the per-slot account of what a refusal means (an image must have a source, so a refused src is a state you render; a refused srcSet candidate or poster simply leaves), is in the repository README. Read it before shipping.

The FuaranSession actor

let session = try FuaranSession(treeJSON: seed)
let tree = try RenderProjection.decodeNode(await session.projectResolved())
await session.applyOp(opJSON)

Single ownership, expressed by construction

The C-ABI session is single-owner: the handle and every call taking it must stay on one executor for its whole lifetime. A Swift actor expresses that contract by construction — every method is actor-isolated, so no two calls touch the raw pointer concurrently, and the pointer never escapes the actor.

Three details are Swift-6-specific and worth knowing before you modify the file:

  • The handle is stored as a UInt bit pattern, not an OpaquePointer. A raw pointer is not Sendable; the bit pattern is, which is what lets the nonisolated deinit read it under strict concurrency. The live pointer is reconstructed per isolated call.
  • It is a let, so free-exactly-once is structural. No other reference can exist at deinit, so freeing there cannot race a concurrent call.
  • fuaran_last_error is a per-thread slot. Each method is a single synchronous C-call sequence with no internal suspension, so it runs to completion on one executor thread — which is why a failing init reads its error envelope in the same synchronous context that saw the failure.

The actor's serialisation is the contract's load-bearing guarantee. A dedicated pinned serial executor is the stricter option if strict thread-affinity is ever required.

projectResolved() versus treeJSON()

treeJSON() is the round-trip exit point. projectResolved() is the same tree with scalar Transform bindings folded to the values they evaluate to — and it is what a decode-only surface should render from, because this side carries no compute evaluator at all (a Binding.Transform resolves to the empty string in the render floor). The core resolves it; the projection renders what it decodes.

Rendering with SwiftUI

The renderer floor is shipped, and its architecture is the thing to understand first.

Every normative render decision lives in a pure function outside the #if canImport(SwiftUI) gate — mediaPlaybackPlan, imagePresentationPlan, customPlaceholder, accessibilityProjection, the drawing lowering, the trend sentiment. Only the thin view application is Apple-gated. The reasoning is recorded in each of those files: the decision is the load-bearing part, and a decision testable on only one platform is a decision nobody re-checks. The plans are asserted on Windows and Linux too.

FuaranNode(node, ctx)      // ctx: BindingContext

The dispatch spine is an exhaustive switch with no default, so a new wire kind is a compile error until its arm lands. The corpus render-coverage gate proves it holds: every node fixture projects through a real arm with zero fallback-arm hits. A visible development placeholder exists as a defined fallback a host may deliberately route through — it is structurally unreachable from the spine, and its use is loud, because it is coverage-tracked.

What "floor" means, arm by arm

Every one of the kinds has an arm; what differs is how much each arm paints.

  • Real — layout containers (including masonry), headings, markdown, math, code blocks, lists, metrics with the trend glyph and sentiment tint, badges, callouts, toasts, facts, label/value rows, icons, progress, skeletons, tabs, steppers, disclosures, the vector drawing canvas, the sparkline (lowered through that same canvas — see below), the tree with its full row structure, and the data grid with its twelve cell kinds.
  • Rendered but not interactive — a link is styled text with no gesture; a tree renders its whole hierarchy, its labels and its open/closed state, but the six key bindings are an interactive host's addition over that identical structure.
  • Placeholder with the full plan applied — an image reserves its aspect, binds its caption and decides its expansion affordance, but there is no network image loader; media discharges all six of its obligations in the plan — including the track menu, in authored order with its default election resolved — and renders a labelled transport tile beside its transcript disclosure, but there is no playback engine; an embed discharges all three of its obligations, sandbox declaration included, but there is no browsing context. Pulling any of them in is not a decision a decoding surface makes for you.
  • Descriptive only — chart, map, mount, fragment reference.

The sparkline is LOWERED, not placeholdered

Sparkline carries a bare bound series and nothing else, so every host that draws one turns that series into geometry. This surface used to draw a fixed grey rectangle from an arm that took no arguments — the series was not merely unpainted but structurally unreachable, a slot that decoded, validated, rendered and did nothing.

It now lowers to the wire's own Drawing and goes through the same canvas an authored Drawing uses. There is one picture-drawing path here and the sparkline is on it; no hand-written vector output survives.

The declining posture was legitimate and was rejected on its merits. A host may keep its placeholder and pin it as a tested contract instead. This surface adopts because it already carries a full vector drawing arm — so the lowering costs a pure function and reuses a seam rather than adding a renderer — and because the geometry is a shared contract rather than a host's opinion: the canvas, the flat-series guard, the one-unit inset, the centring of a lone point and the half-up rounding to two decimals are all fixed by goldens every adopting host is certified against, so a divergence is a failing test rather than an argument. SparklineLoweringTests reproduces every one of them, and proves the comparison can go red rather than assuming it.

A series with nothing to draw keeps a visible fallback. The lowering returns nothing for that case deliberately: the fallback is a host element rather than a shape, and an empty canvas would be a picture claiming to be one.

Interaction, and the boundary it does not cross

FuaranHost wraps a live session and publishes the re-projected tree as SwiftUI state:

control interaction → FuaranHost.dispatch / writeBack → session.applyOp / setState
  → session re-encodes tree_json → RenderProjection.decodeNode re-projects
  → the published tree changes → SwiftUI recomposes

No wire-JSON handling happens outside the session boundary. A validator reject surfaces as a typed lastError while the tree keeps the last-good projection: the interaction is rejected, the UI is not crashed. Re-projection is deliberately coarse — the whole tree is re-decoded per step, on the "measured before optimising" principle.

Button dispatch is live. Form write-back is live for four field kinds — text, number, checkbox and toggle — and every other value-carrying arm is inert by construction. That distinction is the whole finding, and it is not the same as a gap: the inert arms are .disabled(true) over a constant binding, or plain text. The user cannot edit them, so there is no input to drop. Wiring write-back into them would be dead code that merely looked like a fix. Making them live is renderer feature work — real pickers, live slider thumbs — and is deliberately not done here.

If you add a value-carrying arm, it must either wire the write-back in the same change or land disabled. The sealed tree forces the arm to exist; nothing can force it to write.

One interaction boundary is named rather than papered over: a SetState carrying a literal value is wire-complete and is applied, but one carrying valueFrom is not — resolving that binding needs the render context (a Selection reads the clicked row of a named grid), which the dispatch seam does not carry. Leaving it unapplied is the honest answer; writing a placeholder would put a wrong value under a right-looking key.

Writes are SERIALISED, one in flight, FIFO (Phase 1541). Each control interaction is appended to a SerialWriteQueue rather than started as its own Task. Two things were wrong without it, and the second reached a screen: the session applied writes in whatever order the runtime happened to schedule, so two edits to one slot could land backwards; and lastError was written by whichever write finished LAST, so a rejected write that started first and finished second attributed its error to a later write that had been accepted. writeRecords keeps each write's own outcome in call order — a single latched error cannot distinguish a reject-then-success from the reverse — capped at SerialWriteQueue.maxRecords, and writesSettled() is what makes the ordering awaitable rather than sleep-and-hope.

The queue lives outside #if canImport(SwiftUI), in WriteQueue.swift, for the reason the accessibility and trend-sentiment projections do: ordering is a decision, not an Apple detail, and a decision testable on only one platform is a decision nobody re-checks. FuaranHost is an ObservableObject and cannot leave the guard; the guarantee it rests on can.

The server-driven transport — bounded, streaming, and typed

FuaranUIDriver's op stream is consumed incrementally: openOpLineStream() yields each newline-delimited op as it arrives, so an op renders when it arrives rather than when the stream ends. A live SDUI stream is open for as long as the screen is, so the buffered form rendered nothing at all from one — which is why the acceptance test runs against a stream that never ends. The buffered openOpStream() remains on the seam, deprecated in its doc comment, because it is the cheapest thing for a fixture or a finite replay to implement.

Three properties of that path are stated, not assumed:

  • Two explicit caps, OpStreamBounds — 1 MiB per op line, 64 MiB per body, values rather than constants because what a host will read is a deployment decision. A server-driven client applies whatever the server sends, so the op stream is the one place it reads an unbounded amount of attacker-influenced input. A breach is refused BY NAME: a typed .lineCapExceeded / .bodyCapExceeded whose message carries the limit, reaching the driver as .fatal.
  • A declared idle budget. OpStreamBounds.idleTimeout (120 s, matched to the sibling Kotlin surface's) is set on the /ops request rather than left to Foundation's 60-second default, because timeoutInterval is an inter-packet limit for a response still arriving — the same policy the twin spells as idleBudgetMillis. A server-driven stream is idle whenever the screen is, and a default is a policy nobody chose: on the twin the equivalent default was 30 seconds and it killed healthy sessions. Foundation ships no HTTP server, so the socket-level proof of idle survival lives on the Kotlin side (a fixture that goes genuinely quiet for 35 s); what is checked here is that the declaration is made and carries the bound.
  • A non-https base URL is refused unless allowInsecure: true is passed — a typed refusal, never a silent downgrade and never a silent upgrade. Loopback (localhost, 127.0.0.1, ::1) is exempt without the flag, because requiring a certificate for a development fixture server is how an opt-in becomes a permanent default.
  • An event has consequences. postEventApplyingReply applies the server's reply ops through the same apply-then-project path the stream uses, surviving a reject with the last-good tree exactly as run does. postEventOps defaults to the EMPTY sequence rather than to the response body: a server answering with an acknowledgement has not implemented a reply channel, and applying {"ok":true} as a TreeOp would turn every successful event into a validator reject.

The streaming reader is built on URLSessionDataDelegate, not URLSession.bytes(for:) — that API is Apple-Foundation only, and a #if canImport(Darwin) fallback would have left one public seam streaming on Darwin and buffering on every other platform, which is the platform nobody develops on discovering it in production.

What is pending — stated plainly

  • Five capabilities of the platform-baseline wave ARE adopted — media text tracks, Embed, the tooltip trait, Combobox and Tree. Every node fixture in the shared corpus now decodes, and eleven reject vectors that previously answered with the wrong code (or, for the two track vectors and the tooltip one, accepted a malformed document outright) now refuse with the pinned code and path.

  • That residue is CLOSED (Phase 1499). It read: form-field kinds Tokens, Rating and Color unmodelled; WriteToClipboard widened from a bare string to a text source, so a bound payload was refused as WRONG_TYPE; FileUpload silently dropping capture, destination, dropTarget and acceptPaste; Modal.modality and SemanticStyle.direction accepted permissively where the corpus pins a refusal; and the four print-break controls unmodelled. All of it is adopted, along with the vocabulary the same corpus had grown around it — Action.Confirm / Print / Focus, Navigate's text-source route and closed target, Binding.Expr, Binding.Local's codec and commitTo, Binding.Now's grain, Format.Since and Duration, Switch's predicate cases and timed advance, DataGrid's export and transfer keys, and the chart annotation family. Every node fixture decodes and every reject vector refuses with the pinned code at the pinned path, so conformance-residue.txt carries no reject line at all.

    The RENDER half of that adoption landed separately, and the gap is worth recording. Phase 1499 widened the model and the decoder and did not carry the widening into the SwiftUI floor, which is compiled only on macOS — so the sentence above was true of the decoder and silently false of the renderer, and the only thing that said so was a red swift build in CI. The three form-field arms (.tokens chips in authored order, .rating pips over its scale, .color as the swatch its #rrggbb names) and the widened Switch case selection (a literal match against a RESOLVED selector, a when predicate taken on a resolved true only) are in the floor now. The lesson generalises: a vocabulary claim made from the decoder's side is not a claim about this host until the leg that cannot be built on the authoring machine has been run.

    Two strictness rules came with them and are worth naming, because both were places this host silently ACCEPTED a document the corpus refuses rather than merely failing to model one. The integer slot admits a finite number with no fractional part inside the signed 32-bit range and nothing else — it had truncated 2.9 to 2, and a unit test asserted that truncation as reference behaviour it never was. And the JSON number grammar is checked before Double(_:) is asked to parse, because Swift's initialiser accepts 03, 3., .5, +1, 0x1p3, inf and nan — none of them a JSON number, every one of them yielding a perfectly ordinary Double. A raw C0 control character inside a string is INVALID_JSON on the same rule.

  • The declared upload ceilings are adopted (Phase 1548). FileUploadSpec carries maxBytes and maxFiles — both optional, both positive, both §7.1's signed 32-bit slot — and the positivity floor is applied at DECODE, because the format has no refined-integer type: 0 and below are WRONG_TYPE at the member's own path, on the same line as an SrcSetEntry whose width is not positive. A maxFiles beside "multiple":false is carried and not refused: §3.6.23 makes it inert rather than malformed, and a host refusing it would reject documents every other host accepts. Each of the corpus's four refusals is asserted beside a corrected twin in UploadCeilingDecodeTests, so the pair says where the boundary is rather than merely that something was refused.

    The RENDER half withholds the values, deliberately. This floor opens no file picker, so it meets no selection: §3.6.23's obligations 1–3 have nothing to act on here, and obligation 5 is vacuous. What it does instead is follow obligation 4's reasoning — a tier that cannot act on a ceiling records only THAT one was declared, never its number — so uploadCeilingMarkers projects two value-free booleans and the arm renders a marker line built from those alone. An upload declaring neither ceiling renders exactly as it did before the revision: the marker line is absent, not empty. UploadCeilingTests pins it, including the one assertion that can actually fail — that no digit ever reaches the marker.

  • The render-obligation roster is short by two claims, down from nine. The artefact declares twenty; this surface registers fifteen checkers and three declared exemptions. The two that remain — FileUpload/picker-always-present and Modal/aria-modal-only-when-blocking — report as unchecked with no checker registered, and they are deliberately left that way rather than exempted.

    The twentieth claim arrived with Phase 1548 and is the third EXEMPTION. FileUpload/ceiling-recorded-never-enforced is about a marker attribute on the static no-script tier, and this surface emits no attribute bag and no document — the same structural test Image/alt-always-emitted meets. Left unanswered it would have been a regression against a residue file naming two lines, and recording it as a third residue line would have been the wrong answer for the wrong reason: the slots are modelled, so it is not unadopted work. The exemption names the supporting tests, per the rule that an exemption which asserts nothing is true and useless.

    Their REASON changed with Phase 1499 even though their status did not, and the distinction is worth keeping straight. It was that the surface did not model the slots the claims are about; the slots are modelled now — FileUpload carries capture, destination, dropTarget and acceptPaste, and ModalSpec carries modality — so what remains is exactly what the residue line says on its face: no checker is registered. That is still unadopted work rather than a platform limit, and declaring an exemption would turn the gate green over a claim nobody has answered, which is the one thing the mechanism exists to prevent. Not checked is not passed, and silence is never an answer.

  • Three adoption bars are open: contract cards, timed advance, and streamed upload. A host that has not adopted is not thereby exempt — it owes the obligation and has simply not made its answer visible.

  • Grid cells backed by closure accessors read empty, because a closure never rides the wire: an editable cell shows its value without committing, a checkbox reads unchecked, a link is styled without a destination, a progress cell reads zero. That is the decoded-path floor, matched across the surfaces.

  • Binding.Format is not applied in the render floor — the underlying value is shown; number and date formatting is host work.

  • There is no runnable app bundle. SwiftPM cannot emit an Xcode project, so the repository ships the sample view rather than an app. Create an Xcode app target, depend on this package, and set FuaranSampleView() as the root view.

  • There is no formatting gate. swift-format ships with the toolchain but is not wired into run.ps1; match the surrounding style by hand.

Verifying

pwsh ./run.ps1              # swift build + swift test
pwsh ./run.ps1 -SkipBuild   # switches: -SkipBuild / -SkipTests / -Package

or drive SwiftPM directly with swift build and swift test. The reference toolchain is Swift 6 on macOS; the build is portable to a correctly-configured Swift-on-Windows toolchain. run.ps1 skips cleanly with a named message when there is no Swift toolchain, or on Windows when the MSVC linker is absent, and it best-effort builds the sibling Rust core first so the session leg is available.

Two environment variables are worth knowing:

  • FUARAN_RS_STATICLIB_DIR — point the package at a static-library directory to enable the C-ABI session leg.
  • FUARAN_RENDER_FIDELITY — point the render-obligation suite at an alternative artefact file. It exists so the go-red property can be proven: a perturbed scratch copy carrying a claim no checker covers must turn the suite red, and demonstrating that must never involve writing to the shared corpus, which is the oracle every sibling surface answers to.

The conformance legs place the corpus at <repo>/../wire-format-fixtures. When it is missing, the suite distinguishes two cases rather than skipping both: on a standalone clone a skip is honest, but in a cross-host checkout it would mean the gate silently certified nothing while reporting success — so that case fails.

CI is macOS-only, deliberately. Windows and Linux boxes have the Swift compiler and Foundation but not SwiftUI, so the renderer floor and the interaction driver cannot build off an Apple platform.