Skip to content

4.0.0

Latest

Choose a tag to compare

@github-actions github-actions released this 11 May 13:44
· 4 commits to main since this release
9f095ad

This release rebaselines the current InnoFlow implementation, documentation, and release-readiness
gates as the 4.0.0 public contract. It does not require source migration from the current public
APIs.

Release Readiness

  • Updated README, localized README, architecture contract, migration notes, release notes, releasing
    checklist, and doc-parity metadata to describe the 4.0.0 contract surface.
  • Clarified that fixed-arity SelectedStore selection covers one through six explicit state slices,
    select(dependingOnAll:) covers larger explicit sets, and closure selection remains the
    always-refresh fallback.
  • Strengthened release-sync and principle gates so localized install snippets, release target docs,
    localized selection guidance, and throwing .run Markdown snippets cannot drift silently.

Fixed

  • The phase totality diagnostic no longer subclasses SyntaxVisitor. The diagnostic now walks
    Syntax recursively, avoiding a SwiftSyntax internal visitor symbol link failure that surfaced
    when the canonical sample package built the macro tool through the path dependency graph.
  • Phase-managed totality diagnostics now count only the PhaseMap DSL locations that actually
    declare graph coverage (From(...), On(..., to:), and On(..., targets:)). Unrelated member
    accesses inside the phase-map body no longer hide a missing Phase case warning.
  • Store effect cancellation now also tracks non-awaited .merge / .concatenate wrapper tasks.
    cancelAllEffects() and scoped cancellation can tear down composite wrappers even when the
    wrapper itself was not made explicitly .cancellable(...), preventing child sends from escaping
    after the store boundary has been cancelled.
  • Nested .cancellable(...) wrappers now preserve every active cancellation boundary internally.
    Cancelling an outer id reaches already-started inner run tasks in both Store and TestStore,
    rather than only checking the innermost id.
  • EffectRuntime.finish(token:) is now idempotent for cancellation races. A token that is cancelled
    while in flight is still counted as finished once when its task unwinds, but duplicate finish
    calls no longer inflate runtime instrumentation counters.
  • swift build -c release now succeeds on the current Swift 6.3 toolchain. The SIL EarlyPerfInliner previously segfaulted in isCallerAndCalleeLayoutConstraintsCompatible while scanning Store.deinit and TestStore.deinit for inlining candidates. Both deinits are now annotated with @_optimize(none), which sidesteps the crash without changing @MainActor isolated deinit semantics or the public API. Deinit is not a hot path, so the localized optimization loss is negligible. Upstream tracker: swiftlang/swift#88173 (and adjacent #87077 / #87736 / #87462). A minimal in-tree reproducer lives at Repro/SILCrashRepro/ with the full crash dump in Repro/SILCrashRepro/CRASH.txt; retest on toolchain bumps to retire the workaround.
  • swift test -c release now passes the full InnoFlow test suite (212 tests). Five crash-contract subprocess tests (StaleScope*CrashContractTests, PhaseMap*CrashContractTests) previously failed in release because runStaleScopedStoreHarness and runPhaseMapCrashHarness linked against enumerated .build/**/InnoFlow.build/*.o object files — which pulls in whichever configuration happened to be in .build/. When the outer runner was invoked with -c release, the harness picked up release-optimized objects where assertionFailure had been elided, so the subprocess could not abort and the crash contract could not be verified. Both harnesses now inline-compile InnoFlow sources with -Onone + -package-name InnoFlow via the existing innoFlowCoreSourcePaths helper, matching the pattern already used by the release-harness counterparts and removing any dependency on the outer test-runner's build configuration.
  • Five timing-sensitive tests (effectContextUsesStoreClock, effectContextCheckCancellationPassesWhileActive, effectContextCheckCancellationThrowsAfterCancelEffects, storeCombinatorComposition, and manualTestClockResumesSameDeadlineSleepersInInsertionOrder) previously asserted state after a fixed number of await Task.yield() calls. That count was sufficient in debug but not in release — release-mode WMO eliminates some scheduling boundaries, and the remaining actor hops inside Store.executeEffect → Task { walker.walk → driver.startRun → inner Task { gate.wait }} still need scheduler turns. The tests now poll for the observable outcome (e.g., store.throttled == [1], await probe.started == 1, await clock.sleeperCount == N), which is the idiomatic pattern for async-effect tests and was already used for the _completed probe flows elsewhere in the suite. The Store.send(_:) scheduling contract is now documented in ARCHITECTURE_CONTRACT.md under "Store.send(_:) scheduling contract".

Added

  • ScopedStore.select(dependingOnAll:) now mirrors Store.select(dependingOnAll:) for child-state
    projections with more than six explicit dependencies. The overload preserves selective
    invalidation for large derived read models without falling back to always-refresh selection.
  • assertPhaseMapCovers(...) in InnoFlowTesting records a test failure when an opt-in
    PhaseMap.validationReport(expectedTriggersByPhase:) has missing triggers, while still
    returning the structured report for additional assertions.
  • docs/MIGRATION_3_1.md, docs/INSTRUMENTATION_COOKBOOK.md,
    docs/PERFORMANCE_BASELINES.md, and docs/FRAMEWORK_COMPARISON.md document the 3.1 adoption
    path, instrumentation adapters, maintainer baseline policy, and adjacent-library positioning.
  • ScopedStore.isAlive / ScopedStore.optionalState and
    SelectedStore.isAlive / SelectedStore.optionalValue — explicit
    lifecycle accessors for the projection-lifecycle race documented in
    ARCHITECTURE_CONTRACT.md. ScopedStore.state keeps the cached snapshot
    fallback for SwiftUI observer reads, while SelectedStore.value is removed
    and SelectedStore live reads use optionalValue (nil when dead) or
    requireAlive() (release-mode preconditionFailure when dead). The
    accessors expose the same lifecycle signal in a release-tolerant form for
    call sites that prefer to branch on liveness. Backed by four new @Test
    cases verifying both the live-parent and released-parent paths.
  • CONTRIBUTING.md now documents the intentional package layout: core lives in the root Package.swift, the canonical sample lives in Examples/InnoFlowSampleApp/InnoFlowSampleAppPackage, and the SIL inliner reproducer lives in Repro/SILCrashRepro. Consumers depend only on the core package, so sample- or reproducer-only changes do not invalidate consumer build caches.
  • Tests/InnoFlowTests/PhaseMapPerfTests.swift — opt-in (INNOFLOW_PERF_BENCHMARKS=1) dispatch benchmark for PhaseMap covering small (4 phases × 3 transitions), medium (16 × 5), large (64 × 5), and worst-case (last-of-5 match in a 64-phase ring) FSM fixtures. Establishes baseline numbers so a future per-phase transition index — which would require a Hashable constraint on Action and an opt-in PhaseMap shape — can be evaluated against measurement instead of intuition. The PhaseMap doc comment now records the per-action complexity (O(1) phase lookup + linear walk over the matched phase's transitions) and explains why the index work is intentionally deferred until real workloads demand it.
  • StoreInstrumentation.signpost(signposter:name:includeActions:) — new instrumentation factory that bridges store run lifecycle to OSSignposter. Each runStarted event opens an Instruments interval signpost identified by the run's UUID token, the matching runFinished event closes it, and action emissions / drops / cancellations are surfaced as emitEvent signposts on the same name so they appear inline in Instruments' timeline. Token, sequence, and cancellation identifiers are included in signpost messages, while action payloads are redacted by default unless includeActions: true is passed. Pairs cleanly with .osLog(logger:) through .combined(...). Backed by a new @Test case verifying that signpost-instrumented stores preserve runtime behavior on the canonical async load path. ARCHITECTURE_CONTRACT.md lists .signpost alongside .sink / .osLog / .combined as official instrumentation surfaces.
  • Store.select(dependingOnAll:) — Swift parameter-pack-based selection that declares an arbitrary number of explicit dependency key paths and projects them into a derived value. Lifts the previous six-field ceiling on select(dependingOn:) for projections that legitimately depend on more state slices, without forcing the closure-only select(_:) form's .alwaysRefresh re-evaluation on every parent action. Uses a distinct dependingOnAll: argument label rather than overloading the existing dependingOn: so the fixed-arity tuple overloads remain unambiguous at call sites. Backed by a new @Test case driving an eight-field selection through tracked- and untracked-mutation paths and asserting that the projection only refreshes when a declared dependency changes.
  • @InnoFlow(phaseManaged: true) — phase-managed variant of the macro that requires the type to provide a static phaseMap declaration and automatically wraps the synthesized reduce(into:action:) in .phaseMap(Self.phaseMap). Authors no longer have to remember to call .phaseMap(Self.phaseMap) inside body; forgetting the static phaseMap becomes a compile-time error. A boolean marker (phaseManaged: true) is used instead of a WritableKeyPath argument because a keypath argument back into the same type would create a self-referential macro-attribute cycle; the actual phase key path lives inside the static phaseMap value where it belongs. Existing @InnoFlow (no-arg) features continue to work unchanged — they keep authoring .phaseMap(map) explicitly inside body. Backed by a new @Test case verifying both the auto-apply path and the no-op-on-undeclared-action contract through the synthesized reducer.
  • Compile-time phase totality diagnostic for @InnoFlow(phaseManaged: true). The macro now collects the case names of the nested Phase enum (top-level or inside State) and walks the static phaseMap getter body for MemberAccessExprSyntax references; any Phase case that never appears in the phaseMap surfaces a warning anchored on the enum case declaration. Catches the declared-but-unwired authoring hazard at compile time without changing the runtime partial-by-default contract documented in ADR-phase-map-totality-validation.md. The new docs/adr/ADR-compile-time-phase-totality.md records the analysis-vs-reachability trade-off and why graph-based reachability stays out of scope for this layer. Backed by a new @Test in InnoFlowMacrosTests that drives the diagnostic against a fixture with an .orphan Phase case and asserts both the warning surface and the synthesized reducer expansion.
  • docs/adr/ADR-no-builtin-di-container.md, docs/adr/ADR-post-reduce-vs-pre-reduce-phase.md, docs/adr/ADR-reducer-sendable-policy.md — ADRs recording the trade-off analyses behind three previously-implicit framework decisions: why InnoFlow ships construction-time Dependencies bundles instead of a runtime resolver, why PhaseMap runs as a post-reduce decorator instead of a pre-reduce action filter, and why State / Action / effect payloads must be Sendable while the Reducer protocol itself does not.
  • EffectTimingRecorder (InnoFlowTesting) — a test-only actor that captures StoreInstrumentation events (run lifecycle, action emission, cancellation) with monotonic nanosecond timestamps. Pass recorder.instrumentation() to Store(reducer:..., instrumentation:) to record a run; recorder.entries() returns the captured timeline and recorder.dumpJSONL(to:) serialises it as newline-delimited JSON for offline comparison. Timestamps are captured synchronously inside each instrumentation callback (not inside the async append hop) so measured run durations stay faithful to the Store event order even under scheduler contention.
  • scripts/compare-effect-timings.sh — relative p95 / mean comparison between a baseline JSONL and a fresh recorder dump. Exit 1 means a real metric regression; exit 2 means malformed JSONL, incomplete capture, usage error, or missing dependency. Pure bash + jq.
  • scripts/report-effect-timing-trend.sh — non-blocking trend reporter that captures a fresh JSONL (or consumes an existing capture) and prints both mean and p95 deltas against the committed baseline. Metric regressions stay non-blocking, but malformed data and capture failures now fail loudly instead of being reported as ordinary slowdowns.
  • Tests/InnoFlowTests/Fixtures/EffectTimings.baseline.jsonl — committed baseline distribution (10 matched runs) that the new EffectTimingBaselineGate suite compares against under principle-gates' release-mode gate. The gate opts in via INNOFLOW_CHECK_EFFECT_BASELINE=1 so local swift test runs remain silent while CI blocks malformed or incomplete timing captures and reports metric regressions as non-blocking trend output.
  • Tests/InnoFlowTests/Fixtures/EffectTimings.baseline.meta.json — maintainer metadata that records the gate's fixed workload size, metric, tolerance, and refresh posture so baseline regeneration stays decision-complete when runners or toolchains change.
  • store.binding(_:to:) — argument-label alias for store.binding(_:send:) that reads naturally when passing an enum case constructor, as in store.binding(\.$step, to: Feature.Action.setStep). The two labeled spellings are semantically identical and both remain supported on Store and ScopedStore. New code should use an explicit to: or send: label; the older unlabeled closure spelling is retained only as a source-compatibility shim for already-written call sites, not as the documented authoring surface.
  • ManualTestClock.sleeperCount — test-only observable for the number of sleepers currently suspended on the clock. Tests that need to confirm .run / .debounce / .throttle effects have reached their try await clock.sleep(...) registration before calling advance(by:) can poll this instead of relying on a fixed yield count.
  • Four new canonical sample demos in Examples/InnoFlowSampleApp covering the domains flagged as missing by the competitive analysis:
    • AuthenticationFlowDemo — multi-step credentials + MFA flow modeled with PhaseMap (idle → credentials → submitting → mfaRequired → submittingMFA → authenticated / failed) plus a .cancellable("auth-submit", cancelInFlight: true) effect for cancel-and-retry.
    • ListDetailPaginationDemo — paginated list + per-row child reducer + detail scope. Uses ForEachReducer(state:action:reducer:) for row state, scope(collection:action:) for list rendering, and Action.articleActionPath for the generated collection action path. Intentionally phase-light to contrast AuthenticationFlowDemo.
    • OfflineFirstDemo — optimistic local update + debounced save + server-side rollback. Uses .cancellable("offline-save-debounce", cancelInFlight: true) to collapse consecutive edits and _saveConfirmed / _saveRolledBack(previous:reason:) actions to reconcile with repository truth.
    • RealtimeStreamDemo — looping .run subscription driven by the injected tickInterval: Duration dependency and context.sleep. Tests swap in ManualTestClock to advance time deterministically and poll sleeperCount instead of sleeping on wall clock.
  • Ten new @Test cases in InnoFlowSampleAppFeatureTests that exercise each new sample through TestStore — happy paths, failure / retry paths, collection-scoped row actions, debounced-save confirmation vs. rollback, and clock-driven tick receipts.

Changed

  • The canonical Phase-Driven FSM sample now uses @InnoFlow(phaseManaged: true), making the
    sample app demonstrate the preferred phase-managed authoring path instead of the legacy explicit
    .phaseMap(Self.phaseMap) wrapper.
  • Store and TestStore now share a single cancellation-boundary implementation, keeping run
    sequencing, cancel-in-flight, and cancel-all semantics aligned between production and test
    runtimes.
  • ForEachReducer now mutates the matched collection element in place instead of copying the whole
    collection before writing the result back.
  • Collection-scoped store caching now prunes stale IDs by revision, avoiding the per-refresh live-ID
    set and dictionary filter pass while preserving stale-scope cleanup behavior.
  • Tag-triggered release gates now run the principle gate with release-tag enforcement enabled, and
    successful multiline contract searches stay quiet so gate output stays focused on failures.
  • .github/workflows/ci.yml now splits package tests into two parallel jobs — Package Tests (Core) and Package Tests (Sample). Sample-only failures no longer hold up core test feedback, and platform builds depend on the core test job alone, while sample-package builds depend on the sample test job. The principle-gates job depends on both since it runs the full canonical suite.
  • scripts/principle-gates.sh now excludes Repro/ and any .build-* working directory when rsyncing the project into the canonical sample test root. The reproducer is not exercised by CI, and broader .build-* exclusion keeps stray release-build caches out of the staged copy used for sample-package tests.
  • Sample app, DocC walkthrough, .cursor authoring rules, README, and CLAUDE.md now document the new store.binding(_:to:) alias when forwarding an enum case constructor. store.binding(\.$step, to: Feature.Action.setStep) is the recommended form, send: remains supported indefinitely as an explicit labeled alternative, and the old unlabeled closure spelling remains a compatibility shim only.
  • ScopedStore now survives the SwiftUI observer / parent-store-release race without aborting release builds: stale state reads return the last valid cached snapshot and stale sends become silent no-ops. SelectedStore exposes the same liveness signal through optionalValue (nil when dead), while requireAlive() and dynamic-member reads trap with preconditionFailure when the projection is dead. Programming errors that are not lifecycle races — state resolver returning nil at init, or Identifiable.id type mismatch — still trap. The current contract is documented in ARCHITECTURE_CONTRACT.md under "Projection lifecycle contract".
  • ReducerBuilder now preserves composed reducer structure through the full builder chain instead of collapsing every step into nested closure composition. Public authoring (CombineReducers { … } with Reduce, Scope, IfLet, IfCaseLet, ForEachReducer) is unchanged. Construction-side benchmarks (debug build) show −29%/−34%/−40% at N=2/8/32; dispatch-side benchmarks show modest −3 to −5% gains in debug and are expected to improve further in release builds where @inlinable unlocks specialization across the builder boundary.

Internal

  • @InnoFlow now emits a Fix-It-backed warning when a feature's State declares @BindableField var <name> but the associated Action enum has no matching case set<Name>(Value) (or any set* case whose suffix matches case-insensitively, so mfaCodesetMFACode is accepted). The diagnostic is strictly opt-out-safe: it never fires when State or Action is a typealias (for example SampleArticleRowFeature.Action = ...), and case _setX(Value) serves as an explicit escape hatch for private setters. Payload type is inferred from the literal initialiser or the explicit type annotation; without that signal, the warning is emitted without a Fix-It. Backed by four new InnoFlowMacrosTests cases covering positive match, acronym casing tolerance, typealiased-Action skip, and multi-field partial coverage.
  • docs/DEPENDENCY_PATTERNS.md is now the canonical authoring guide for how construction-time Dependencies bundles enter reducers. The document covers the three canonical patterns (single service / composite bundle / framework-provided clock), the three test-substitution scenarios, preview conventions, the list of anti-patterns InnoFlow explicitly rejects (singletons, property-wrapper resolvers, runtime service locators), and the charter rationale for not shipping a DI container. The English README, README.kr.md, README.jp.md, README.cn.md, and ARCHITECTURE_CONTRACT.md link to it from their dependency / quick-link sections. scripts/principle-gates.sh now enforces that the document exists and that every README and the architecture contract link to it.
  • scripts/principle-gates.sh now opts the release-mode test run into the EffectTimingBaselineGate suite via INNOFLOW_CHECK_EFFECT_BASELINE=1. The gate drives a probe Store through a fixed workload, captures the recorder's JSONL output to a temp file, and invokes scripts/compare-effect-timings.sh to report mean regressions without blocking CI. Malformed or incomplete timing captures still fail loudly. Stricter timing observations belong to scripts/report-effect-timing-trend.sh, which preserves hard failures for malformed or incomplete data instead of flattening them into non-blocking regressions. Maintainers can regenerate Tests/InnoFlowTests/Fixtures/EffectTimings.baseline.jsonl deliberately with INNOFLOW_WRITE_EFFECT_BASELINE=<path>.
  • Added a release-build regression guard to scripts/principle-gates.sh. The gate runs swift build -c release in an isolated build path so release object files cannot leak into .build/ and pollute subprocess harness linking.
  • Added a release-test regression gate to scripts/principle-gates.sh. The gate runs swift test -c release in an isolated build path so tests that pass under debug but regress under release optimization (flaky timing assumptions, harness configuration leakage, SIL inliner variants) surface in CI instead of only manual runs.
  • Added release-mode subprocess tests that verify ScopedStore.state, ScopedStore.send, collection-scoped projections, and SelectedStore.optionalValue / SelectedStore.requireAlive() follow the current dead-projection contract after the parent store is released.
  • Added Repro/SILCrashRepro/ — a minimal, standalone SwiftPM package that reproduces the Swift 6.3 EarlyPerfInliner crash on a generic @MainActor class with an isolated deinit that stores result-builder-composed value types. Kept in-tree so toolchain bumps can retest whether the InnoFlow @_optimize(none) workaround is still required.