Skip to content

About

Delegate a Swift 6 strict-concurrency migration to agents: the compiler is the judge, a journal is the single source of truth. Splits diagnostics into atomic tasks and verifies each by build, tests, and diagnostic counts. Spans the KMP boundary.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

28 Commits

Folders and files

Repository files navigation

strictmigrate

Delegate a Swift 6 strict-concurrency migration to agents β€” with the compiler as judge and a journal as the single source of truth.

The official migration guide is a great 40-page document. strictmigrate turns those 40 pages into an executable task queue: the compiler counts the verdicts, the journal records where the migration stands, and the agent (or a human) holds the pen in between.

v0.5 β€” across the KMP boundary. Swift-side diagnostics, Kotlin-side fixes: routing, symbol slicing, and edit permissions now span the language boundary of a Kotlin Multiplatform project.

$ strictmigrate status
strictmigrate β€” Swift 6 strict concurrency migration
journal: strictmigrate.yaml.journal

Target            Level      Sendable  Isolation  Region  Other  Total   Progress
─────────────────────────────────────────────────────────────────────────────────────────────
ImagePipelineCore complete          1          3       0      0      4   β–“β–“β–“β–“β–“β–‘β–‘β–‘β–‘β–‘    50%
DemoApp           complete          0          0       2      0      2   β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘     0%

Total: 6 remaining concurrency diagnostics (from 8 initial) β€” 25% complete across 2 targets

That table is the migration meeting. No wiki page, no memory of "what we already fixed" β€” data.

Why

Migrating a large Swift 5 codebase to Swift 6 strict concurrency means hundreds to thousands of Sendable / actor-isolation diagnostics the moment you raise StrictConcurrency from minimal toward complete:

  1. Scale β€” too many diagnostics to triage in an afternoon, or a quarter.
  2. Official support stops at docs and syntax β€” swift-migrator flips flags and syntax; deciding how a type becomes Sendable, or where an actor boundary should sit, is semantic work. Verifying that work is mechanical: build, count, test.
  3. Delegating it wholesale to an agent fails structurally β€” context limits cause partial fixes, sessions regress previous work, and nobody records how far you got. There is no revert boundary.

strictmigrate's bet: split the diagnostics into atomic tasks, let an agent execute one task at a time, judge each result deterministically (build + tests + diagnostic counts), and journal every verdict so any session β€” human, agent, or CI β€” reads the same state. v0.1 ships the measurement and journaling half; the agent loop lands in v0.3.

Install

Requires a Swift 6+ toolchain (Xcode 16+ on macOS).

$ git clone https://github.com/ictechgy/strictmigrate
$ cd strictmigrate && swift build -c release
$ cp .build/release/strictmigrate /usr/local/bin/   # or anywhere on PATH

Quickstart

$ cd YourSwiftPackage

$ strictmigrate init
Created strictmigrate.yaml.journal

$ strictmigrate measure
Building with -strict-concurrency=complete …
build: swift build --no-color-diagnostics --scratch-path .strictmigrate/measure-scratch -Xswiftc -strict-concurrency=complete (exit 1)
diagnostics: 5 tracked (sendable 1, isolation 3, region 1, other 0); 0 unrelated, not counted

Target             Sendable  Isolation  Region  Other  Total   Ξ” vs previous
ImagePipelineCore         1          3       1      0      5   new
DemoApp                   0          0       0      0      0   new

journal updated: strictmigrate.yaml.journal

$ strictmigrate status                  # pretty table (above)
$ strictmigrate status --format markdown # paste into a PR or issue
$ strictmigrate status --format json     # feed CI or dashboards

Commit strictmigrate.yaml.journal. Raw build logs land in .strictmigrate/, which ignores itself.

The task queue (manual mode)

$ strictmigrate slice
Queued 5 tasks (dropped 0 stale queued) β€” journal updated: strictmigrate.yaml.journal

Next up:
  t-0001  ImagePipelineCore  Sources/ImagePipelineCore/Decoder.swift  [shared]  (sendable-violationΓ—1)
  t-0002  ImagePipelineCore  Sources/ImagePipelineCore/Renderer.swift  [renderSync]  (actor-isolationΓ—1)
  t-0003  ImagePipelineCore  Sources/ImagePipelineCore/Renderer.swift  [spawnWork]  (actor-isolationΓ—2)
  t-0004  DemoApp            Sources/DemoApp/main.swift  [(file scope)]  (region-violationΓ—1)
  ...

$ strictmigrate next
Task t-0001 β€” target ImagePipelineCore
File: Sources/ImagePipelineCore/Decoder.swift
Symbol(s): shared

Diagnostics to fix β€” only these:
  - Sources/ImagePipelineCore/Decoder.swift:16:23 [sendable] warning: static property 'shared' is not concurrency-safe …

Scope rules (hard boundaries):
1. Modify ONLY `Sources/ImagePipelineCore/Decoder.swift`, ONLY the symbol(s) above.
2. Do not touch other files or symbols, even if you see problems there β€” they belong to other tasks.
…
Verify (the compiler is the judge):
  swift build --no-color-diagnostics -Xswiftc -strict-concurrency=complete
  strictmigrate measure   # updates the journal; closes t-0001 when clean

Paste that prompt into your editor, a chat agent, or a teammate β€” fix it, then strictmigrate measure again. The measure run reconciles the queue automatically: tasks whose diagnostics are gone close as passed; partial fixes stay open. Nothing is remembered in anyone's head; it is all in the journal.

The agent loop (v0.3)

Let an agent work the queue itself:

$ strictmigrate run --task t-0003 --adapter claude
Measuring current state before dispatch …
── t-0003 [ImagePipelineCore] Sources/ImagePipelineCore/Renderer.swift Β· spawnWork
   attempt 1/2: dispatching claude-code …
   agent finished (exit 0); measuring …
   passed β€” committed aca26a2, journal updated.

1/1 passed β€” journal: strictmigrate.yaml.journal

What happened in between, all deterministic:

  1. Clean-tree precondition β€” git must be clean (harness state like .strictmigrate/ and agent session dirs are whitelisted). The revert boundary only works from a clean tree.
  2. Dispatch β€” the task prompt goes to the adapter (claude uses claude --print --permission-mode acceptEdits; the agent never runs builds β€” judging is not its job).
  3. Scope check β€” the agent may edit exactly the task's file. Any other edit (even bookkeeping outside whitelisted state dirs) reverts everything, the attempt fails, and the reason lands in the journal.
  4. Verdict β€” a fresh strict build runs; the task's symbols must be clean and no symbol anywhere else may have gained diagnostics. Aggregate improvement is not enough.
  5. Commit or revert β€” pass: exactly the task's file is committed (one task = one commit = one revert boundary) and the hash lands in the journal. Fail: edits are discarded, the tree returns to exactly its pre-state, and after --max-attempts the task is reverted with per-attempt notes.

Any CLI agent works today via the generic adapter:

$ strictmigrate run --adapter command \
    --adapter-command 'my-agent --prompt-file $STRICTMIGRATE_PROMPT_FILE'

Dedicated adapters: --adapter codex (Codex CLI, workspace-write sandbox) and --adapter acp --acp-command 'claude-code-acp' for any Agent Client Protocol agent β€” the JSON-RPC handshake, session, and prompt turn run over stdio, with streamed agent messages kept as the transcript.

KMP boundary (v0.5)

Kotlin Multiplatform teams hit Swift 6 strict concurrency from the iOS side: the app's build flags every Kotlin-exported non-Sendable type at each crossing, while the fix usually lives in the shared Kotlin module. strictmigrate now works across that boundary β€” measurement stays on the Swift side (the compiler that judges), while attribution, slicing, and edit permissions extend into the Kotlin source:

  • .kt files resolve to their Kotlin declarations (fun/class/object/interface/val, raw strings, nested comments) for symbol-level tasks.
  • Kotlin files attribute to module/sourceSet targets via Gradle directory conventions β€” no Gradle run needed β€” and discovered source sets appear in the journal even at zero diagnostics.
  • Boundary routing: when a task's Swift diagnostics name a non-Sendable type that is declared in Kotlin, the task prompt points at the Kotlin declaration ("prefer fixing it there") and the executor's scope check allows that file β€” deterministically, from the same function on both ends. One task still means one commit, now possibly spanning both languages.

A runnable version of this scenario lives in Examples/KmpBoundary: a Swift app, a shared Kotlin module, and a boundary violation whose fix touches both sides. The pure-Kotlin strict-mode adapter remains deferred until the Kotlin compiler grows one (KT-72087).

Test and ThreadSanitizer verdicts

Pass --tests (or --tsan) to make the suite part of the judge:

$ strictmigrate run --task t-0007 --adapter claude --tsan
   agent finished (exit 0); measuring …
   running tests under ThreadSanitizer …
   passed β€” committed 1f2a3b4, journal updated.

Tests run only once the whole package builds β€” results alongside a broken build elsewhere would be noise. Test failures or TSan race reports fail the attempt (edit reverted), and the journal records verdict: { build: pass, tests: 47/47, tsan: clean }. Regressions found outside the task's target are labeled with the target they broke (DemoApp:Sources/DemoApp/main.swift [count] +1), so cross-target fallout is visible at a glance.

Prompts, transcripts, and build logs for every attempt are kept under .strictmigrate/ for post-mortems.

A live example lives in Examples/DemoConcurrency β€” a tiny package with intentional violations of all three categories.

Commands

Command What it does
strictmigrate init Create an empty journal in the package root.
strictmigrate measure Build with strict concurrency, count diagnostics per target, update the journal, reconcile open tasks.
strictmigrate status Render the journal as a report (pretty, markdown, json).
strictmigrate slice Cluster the last measurement into atomic tasks (one symbol group = one task).
strictmigrate next Print the next task with a ready-to-paste prompt (--peek to leave it queued).
strictmigrate run Execute tasks through an agent: dispatch, judge, commit or revert.
strictmigrate tasks List tasks and their statuses.
strictmigrate skip Mark a task skipped so the queue moves past it.

measure options

  • --level minimal|targeted|complete β€” strict-concurrency level to measure at (default complete; the honest distance to Swift 6).
  • --with-tests β€” include test targets (swift build --build-tests).
  • --incremental β€” reuse the package's .build. Fast, but warnings from files that did not recompile are not re-emitted and will be undercounted. By default measure builds in a throw-away scratch directory so every diagnostic is emitted and counts are reproducible.
  • -Xswiftc <flag> β€” pass extra swiftc flags (repeatable).
  • --xcresult <path> β€” parse an existing result bundle instead of running swift build (see below).
  • --verbose β€” list every tracked diagnostic after the summary.

A failing build is the normal case mid-migration: measure reports the exit code and counts what the compiler said. Diagnostics that are not concurrency-related (syntax errors, style warnings) are counted as unrelated and never enter the journal.

Xcode projects

v0.1 measures SwiftPM packages directly. For Xcode projects, build once with a result bundle and hand it over:

$ xcodebuild -scheme App -destination 'generic/platform=iOS' \
    -resultBundlePath build.xcresult \
    OTHER_SWIFT_FLAGS='$(inherited) -strict-concurrency=complete' build
$ strictmigrate measure --xcresult build.xcresult

If a Package.swift is present (xcodebuild on a package), diagnostics are attributed to package targets; otherwise they land under (unattributed) until target mapping ships for project files.

The journal

One YAML file per repository, commit target, edited by measure and safe to edit by hand:

version: 1
targets:
  ImagePipelineCore:
    level: complete          # minimal | targeted | complete
    diagnostics_baseline:    # latest measured counts
      sendable: 1
      isolation: 3
      region: 0
      other: 0
    diagnostics_initial:     # counts at first contact β€” progress denominator
      sendable: 2
      isolation: 3
      region: 0
      other: 0
    last_measured_at: '2026-09-06T00:00:00Z'
tasks: []                    # v0.2+: atomic tasks (one task = one commit = one revert boundary)

status computes progress as 1 βˆ’ baseline/initial per target. Regressions are reported honestly (you can see βˆ’50%), never clamped.

How counting works

  • Collection β€” swift build output is parsed from file:line:col: error|warning: lines (ANSI colors and hyperlink markup stripped, duplicates from the emit-module/per-file phases collapsed), or from xcresulttool JSON for result bundles.
  • Attribution β€” files map to targets by longest source-root prefix from swift package describe. Clean targets appear with zeros; unmapped files land under (unattributed).
  • Classification β€” two deterministic layers: newer toolchains append stable diagnostic ids ([#RegionIsolation::SendingRisksDataRace], [#ActorIsolatedCall], [#MutableGlobalVariable]) which are preferred; older output falls back to keyword rules on the message text. Categories (sendable, isolation, region, other) are a prioritization aid β€” the tracked total is exact regardless of bucketing.

Known ground roughness, handled explicitly: incremental builds don't re-emit cached warnings (fresh scratch by default), xcresult locations are 0-based (normalized), and the root issues mirror per-action summaries (deduplicated).

How slicing works

  • Clustering β€” one task per (target, file, enclosing symbol). A lightweight brace-depth scanner resolves each diagnostic to its enclosing declaration, so half-fixed symbols never split across tasks β€” the classic cause of re-appearing diagnostics. Diagnostics in top-level code cluster as (file scope).
  • Ordering β€” leaf targets first: swift package describe dependency edges are topologically sorted so dependencies are fixed before dependents, and each fix gets confirmed by downstream recompiles. Within a target, smaller tasks come first.
  • Reconciliation β€” measure resolves current diagnostics to (file, symbol) identities and closes open tasks whose symbols are clean, recording the build verdict honestly (a task can pass while the overall build still fails elsewhere). Partial fixes stay open; the queue itself is always re-derivable via slice.

Design principles

  • The compiler is the judge. Verdicts (build, tests, counts) are deterministic. The harness reports them, never manipulates them.
  • The journal is the single source of truth. "How far along is the migration?" is answered by data, not memory or PR prose. CI, meetings, and agents all read the same file.
  • Agents hold the pen only. In v0.3, an agent (claude-code, codex, ACP) writes the fix for one task; parsing, slicing, verdicts, journaling, and reverting stay deterministic code.
  • Atomic tasks. One task = one symbol group = one commit β€” the revert boundary is always unambiguous.
  • Fully local. Diagnostics, journal, and commits stay in the repository. Nothing is sent anywhere.

Roadmap

  • v0.6 (undecided) β€” pure-Kotlin strict-mode adapter, deferred until the Kotlin compiler grows compiler-enforced data-race checking (KT-72087); verdict hooks for CI gates.

Development

$ swift test                            # unit tests + recorded-fixture tests
$ STRICTMIGRATE_SKIP_INTEGRATION=1 swift test   # skip the real-toolchain integration tests

The integration tests compile Examples/DemoConcurrency with your local toolchain and assert exact diagnostic counts β€” they are the guardrail for the parsing pipeline.

License

MIT

About

Delegate a Swift 6 strict-concurrency migration to agents: the compiler is the judge, a journal is the single source of truth. Splits diagnostics into atomic tasks and verifies each by build, tests, and diagnostic counts. Spans the KMP boundary.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages