From ab7c0916b12ee551573caae24b506ceb5404d6ee Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 2 May 2026 06:38:30 +0100 Subject: [PATCH 1/2] docs(guides): add migration-playbook.adoc + frontier-guide v1.0 living-doc header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/guides/migration-playbook.adoc as the systematic companion to frontier-guide.adoc — the source-of-truth for re-decomposition rules when porting ReScript / TypeScript codebases into AffineScript. Covers: the cardinal rule (re-decompose, do not transliterate), source-pattern indices for ReScript and TypeScript, decision criteria (`Option` vs `Result`, `mut` vs effect, `ref`/`mut`/`own`, linear vs affine), and a file-buffer anti-pattern paired with its re-decomposed form. Bumps frontier-guide.adoc to v1.0: - adds :revnumber: / :revdate: attributes - adds a "canonical, living document" status note pointing at the migration-playbook - cross-links the playbook from "What to Read Next" - adds a Revision History appendix so subsequent edits have a place to land — addresses the gap where the guide had no surface for incremental updates and downstream consumers (idaptik, etc.) had no way to see what had changed. Both files render cleanly with asciidoctor. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/guides/frontier-guide.adoc | 28 ++- docs/guides/migration-playbook.adoc | 272 ++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 docs/guides/migration-playbook.adoc diff --git a/docs/guides/frontier-guide.adoc b/docs/guides/frontier-guide.adoc index 61cad74..da6790b 100644 --- a/docs/guides/frontier-guide.adoc +++ b/docs/guides/frontier-guide.adoc @@ -1,10 +1,19 @@ // SPDX-License-Identifier: PMPL-1.0-or-later = AffineScript Frontier Guide: The Unveiling Jonathan D.A. Jewell -2026-04-11 +v1.0, 2026-04-11 :toc: :icons: font :source-highlighter: rouge +:revnumber: 1.0 +:revdate: 2026-04-11 + +[NOTE] +==== +Status: **canonical, living document.** This guide is the design reference for AffineScript itself and for any migration into AffineScript. Edits are welcome; record them in the <> appendix so the timeline is visible to future readers and AI agents. + +For systematic translation patterns from ReScript, TypeScript, or another `-script` family language, read this guide first, then the link:migration-playbook.adoc[Migration Playbook]. +==== The Frontier Guide takes you from "I write JavaScript / Python" to "I understand why AffineScript's design choices produce better programs." @@ -359,6 +368,8 @@ fn fetch_with_retry( == What to Read Next - link:warmup/README.adoc[Warmup scripts] — hands-on exercises, 15 minutes each +- link:migration-playbook.adoc[Migration Playbook] — systematic re-decomposition rules for porting ReScript / TypeScript / other `-script` codebases into AffineScript +- link:frontier-programming-practices/Human_Programming_Guide.adoc[Frontier Programming Practices] — the wider design philosophy (v2.0, 2026-04-10) - link:../specs/SPEC.md[Language Specification] — the authoritative grammar and semantics - link:../specs/SETTLED-DECISIONS.adoc[Settled Decisions] — architectural choices and why they were made - link:../DESIGN-VISION.adoc[Design Vision] — the long view @@ -383,3 +394,18 @@ AffineScript. Error messages are translated back to the face you chose, so Additional faces (JS-face, Pseudocode-face, and others) are on the roadmap. See link:../specs/faces.md[faces.md] for the architecture. + +[appendix#revision-history] +== Revision History + +This document is intended to evolve. When you change it — adding a chapter, sharpening an example, retiring an obsolete claim — record the change here so that downstream readers (especially AI agents loading the guide at session start) can see what has moved. + +[cols="1,1,3"] +|=== +| Revision | Date | Notes + +| 1.0 +| 2026-04-11 +| Initial unveiling. The six X-Script problems and AffineScript's answers; chapters 1–6; complete-program example; faces. + +|=== diff --git a/docs/guides/migration-playbook.adoc b/docs/guides/migration-playbook.adoc new file mode 100644 index 0000000..0a9d73a --- /dev/null +++ b/docs/guides/migration-playbook.adoc @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += AffineScript Migration Playbook: Re-decomposition Patterns +Jonathan D.A. Jewell +v1.0, 2026-05-02 +:sectnums: +:toc: left +:icons: font +:source-highlighter: rouge +:revnumber: 1.0 +:revdate: 2026-05-02 + +[NOTE] +==== +This guide is the **systematic companion** to link:frontier-guide.adoc[`frontier-guide.adoc`]. + +* The Frontier Guide unveils what AffineScript *is* — read it if you are starting a new program. +* This playbook describes how to **carry an existing codebase across the boundary** — read it if you are translating ReScript, TypeScript, or another `-script` family language into AffineScript. + +The machine-readable companion for AI agents is link:frontier-programming-practices/AI.a2ml[`AI.a2ml`]. +==== + +== The Cardinal Rule + +**Re-decompose. Do not transliterate.** + +A 1:1 syntactic port — `let mutable` becomes `mut`, `option` becomes `Option[T]`, `try/catch` becomes `try/catch`, classes become records-with-methods — produces code that _type-checks in AffineScript while still carrying the design problems AffineScript was built to solve_. + +The Frontier Guide's thesis: every X-Script's six pathologies share one root — *the runtime does not know the program's intent*. Translation must therefore re-express *intent*, not just syntax. If a faithful port leaves the original design untouched, the destination language is being used as a syntax veneer and none of its guarantees do any work. + +The rest of this document is a catalogue of common source-language patterns and the re-decompositions they invite. + +== How to Use This Playbook + +. Start from the source file. Identify the patterns it contains using <>. +. For each pattern, read the **Decomposition** column — that is the AffineScript *shape*, not just the AffineScript *syntax*. +. If the source mixes several patterns into one structure (a class with mutable fields, an exception path, and a callback), expect the AffineScript output to have **more, smaller** declarations — not the same number of larger ones. +. Where two decompositions are valid, the <> table tells you which one fits. +. The <> section shows what a faithful-but-monolithic translation looks like, paired with its re-decomposed form. + +[#source-pattern-index] +== Source-Pattern Index + +=== ReScript → AffineScript + +[cols="1,2,2"] +|=== +| Source | Decomposition | Why + +| `let mutable x = ...` +| `mut` field on an owned record, **or** lift to a `State` effect +| Local mutation in a single owner stays local; mutation visible across calls becomes an effect. + +| `ref<'a>` (mutable cell) +| Same choice as `let mutable`; default to lifting to an effect when the cell crosses a function boundary +| A bare `ref` is mutation that has already escaped its scope — typically the wrong default. + +| `option<'a>` (built-in) +| `Option[T]` +| Direct mapping; no decomposition needed. + +| `result<'a, 'e>` +| `Result[T, E]` +| Direct mapping; tighten `'e` from `string` or `exn` to a named error type if you can. + +| `exception Foo` + `try/catch` +| `Result[T, E]` where `E` is a named variant covering each failure +| Replaces an untyped throw path with an exhaustive return type. The compiler forces every caller to handle the `Err` shape. + +| `Js.Promise.t<'a>` / async ReScript +| `Async` effect on the function row +| Promises silently allow racing, ignoring errors, and detached lifetimes. The `Async` effect makes lifetime and error explicit. + +| ReScript object types `Js.t<{..}>` +| Records with row variables `{ field: T, ..r }` +| Row polymorphism replaces structural object types principle-for-principle. + +| Polymorphic variants `[#A \| #B]` +| Open variant rows / sum types +| Same idea, integrated with the rest of the type system rather than parallel to it. + +| Module functors +| Row-polymorphic functions parameterised over their effects +| Most functor uses are an indirect way to parameterise over a small set of operations — effects do this directly. + +| Class via PPX (`%@react.component`, etc.) +| Owned record + standalone functions; lift effectful methods into effect rows +| A class is a tangle of state, methods, and implicit lifetimes. Each strand becomes its own declaration. + +| `unit` returns from impure code +| `() / IO` (or whichever effects apply) +| In ReScript, `unit` says nothing about whether the function did IO. In AffineScript, the effect row makes it visible. +|=== + +=== TypeScript → AffineScript + +[cols="1,2,2"] +|=== +| Source | Decomposition | Why + +| `T \| null \| undefined` +| `Option[T]` +| Frontier Guide chapter 1: one nothing, not three states. + +| `throw new Error()` + `try/catch` +| `Result[T, E]` for recoverable errors; `Exn[E]` effect for cross-cutting failure +| A bare `throw` in TypeScript is invisible at the call site. Both replacements make it visible. + +| `Promise` / `async function` +| `Async` effect +| Same reasoning as the ReScript case. + +| `class Foo { private x; method() {} }` +| Owned record + standalone functions; lift mutation to `mut` or `State` +| Encapsulation comes from ownership and module scope, not from class privacy. + +| `any` / `unknown` +| Forbidden in AffineScript output. Pin the type, or describe the unknown shape with a row variable. +| `any` is the absence of a thesis. Every translation must replace it with one. + +| Discriminated unions (`{ kind: "a" } \| { kind: "b" }`) +| Sum types with named constructors; `match` on the constructor +| Pattern matching is exhaustive; the compiler enforces what TypeScript can only suggest. +|=== + +[#decision-criteria] +== Decision Criteria + +When a source pattern admits more than one AffineScript shape, choose with these tests. + +=== `Option[T]` vs `Result[T, E]` + +* **`Option[T]`** when *absence is normal* — a lookup that may not find anything, a config field that may be unset. +* **`Result[T, E]`** when *absence has a reason you can describe* — parse failure, validation error, IO error. + +If you find yourself reaching for `Result[T, ()]` or `Result[T, String]`, you probably wanted `Option[T]` (or a richer `E`). + +=== `mut` vs effect + +* **`mut`** for local mutation contained in a single owner — buffer construction, an accumulator inside a function. +* **`State[S]` effect** for mutation that must be observable across calls — game state, session state, a setting visible to many subsystems. + +If two callers need to see the same mutation, it is no longer *local* — lift it. + +=== `ref` vs `mut` vs `own` + +* **`ref Buffer`** — the caller keeps the value and the function only reads it. +* **`mut Buffer`** — the function modifies in place; the caller keeps using the value afterwards. +* **`own Buffer`** — the function consumes the value; the caller cannot use it after the call. + +The default for resources (files, sockets, tokens, allocations) is `own` — anything else hides the lifetime. + +=== Linear (`@linear`) vs affine (`own`) + +* **`own`** (affine) — *may* be used at most once. Dropping is allowed. +* **`@linear`** — *must* be used exactly once. Dropping is a compile error. + +Prefer `own`. Reach for `@linear` only when forgetting to consume the value is a real bug — typically protocol handles, transactions, and capability tokens. + +[#anti-patterns] +== Anti-patterns: Faithful-but-monolithic Translation + +The following ReScript is a small file buffer that conflates state, lifetime, and IO. + +[source,rescript] +---- +// ReScript — original +type fileBuffer = { + mutable data: array, + mutable open_: bool, +} + +let make = (): fileBuffer => { data: [], open_: true } + +let read = (b: fileBuffer): string => + if b.open_ { Array.joinWith(b.data, "") } else { raise(Failure("closed")) } + +let write = (b: fileBuffer, s: string): unit => { + if !b.open_ { raise(Failure("closed")) } + b.data = Array.concat(b.data, [s]) + Console.log("wrote " ++ s) +} + +let close = (b: fileBuffer): unit => { b.open_ = false } +---- + +=== Faithful-but-monolithic translation — don't do this + +[source,affinescript] +---- +// AffineScript — same shape, none of the guarantees doing any work +type FileBuffer = own { + data: mut Array[String], + open_: mut Bool, +} + +fn make() -> own FileBuffer { FileBuffer { data: [], open_: true } } + +fn read(b: ref FileBuffer) -> String { + if b.open_ { String.join(b.data, "") } else { panic("closed") } +} + +fn write(b: mut FileBuffer, s: String) -> () { + if !b.open_ { panic("closed") } + b.data = b.data ++ [s]; + // IO is silently invisible — same problem as the original +} + +fn close(b: mut FileBuffer) -> () { b.open_ = false } +---- + +This compiles. It is also no better than the ReScript original: the `closed` invariant is still a runtime check, the IO is still invisible, and `close` does not actually consume the buffer. + +=== Re-decomposed translation + +[source,affinescript] +---- +// Two types — a closed buffer is statically distinguishable from an open one +type OpenBuffer = own { data: Array[String] } +type ClosedBuffer = own { data: Array[String] } + +effect IO { fn log(s: String); } + +fn make() -> own OpenBuffer { OpenBuffer { data: [] } } + +// read takes a borrow — caller keeps the buffer: +fn read(b: ref OpenBuffer) -> String { String.join(b.data, "") } + +// write is in-place; IO is in the row: +fn write(b: mut OpenBuffer, s: String) -> () / IO { + b.data = b.data ++ [s]; + IO.log("wrote " ++ s) +} + +// close consumes Open and returns Closed — "use after close" becomes a type error: +fn close(b: own OpenBuffer) -> own ClosedBuffer { ClosedBuffer { data: b.data } } +---- + +What changed: + +. `closed` moved from a runtime field to a *type* (`OpenBuffer` vs `ClosedBuffer`) — "use after close" is now a compile error. +. `close` consumes its argument (`own`) — there is no `b` left to misuse afterwards. +. `IO` is in the effect row of `write` — every caller of `write` declares it transitively. +. The mutable boolean is gone; `data` is reached only through `mut OpenBuffer`, which makes ownership explicit. + +The two versions are roughly the same number of lines. They are different programs. + +== Lessons from Real Migrations + +Case studies from in-flight migrations land in link:lessons/[`docs/guides/lessons/`]. + +If you complete a non-trivial `.res → .affine` translation and the re-decomposition reveals something future translators would benefit from, write it up there. The format is loose: original snippet, faithful port, re-decomposed port, and what the second one buys you. + +== See Also + +* link:frontier-guide.adoc[Frontier Guide: The Unveiling] — the *what* and *why* of AffineScript's design. +* link:frontier-programming-practices/Human_Programming_Guide.adoc[Frontier Programming Practices] — the wider design philosophy. +* link:frontier-programming-practices/AI.a2ml[`AI.a2ml`] — machine-readable companion, read by AI agents at session start. +* link:../specs/SETTLED-DECISIONS.adoc[Settled Decisions] — architectural choices and why they were made. +* link:../specs/SPEC.md[Language Specification] — authoritative grammar and semantics. + +[appendix] +== Revision History + +[cols="1,1,3"] +|=== +| Revision | Date | Notes + +| 1.0 +| 2026-05-02 +| Initial draft. ReScript and TypeScript pattern indices, decision criteria, file-buffer anti-pattern. Companion to `frontier-guide.adoc` v1.0. +|=== From 9d03dd508a8e25f0c39ae473e2ee52a929899862 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 2 May 2026 07:15:30 +0100 Subject: [PATCH 2/2] docs(lessons): add UserSettings design walk-through; fix playbook lessons-pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/guides/lessons/migrations/idaptik-user-settings.adoc — the second migration case study, paired with the existing idaptik-hitbox entry. Unlike hitbox (a pure-leaf compilable translation), UserSettings has 5+ external deps (Storage/Audio/DesktopIntegration/GetEngine/Option) and uses Float arithmetic, neither of which v0.1.0 can resolve, so this lesson is explicitly marked **design-only**. The walk-through still pressure-tests the playbook against a realistic, dependency-heavy file. Stress-test outcome: - Playbook held cleanly on stringly-typed→sum-type, unit→IO-effect, Option direct mapping, and let-_-discarded-Result patterns. - Surfaced three small gaps: 1. Service-locator globals (`GetEngine.get(): Option`) aren't named in the source-pattern index. Lift-to-effect is the right answer; needs one row. 2. Coupled-write composition (one user action that updates both an in-memory subsystem AND persists) isn't covered by the decision criteria. 3. The playbook pointed lessons/ at the wrong folder — that's the 10-lesson tutorial track, not the migration case-study channel. Fixed inline. Gap 3 fixed in this commit. Gaps 1 and 2 are playbook-text changes deferred to a v1.1 follow-up so this PR stays focused. The "Lessons from Real Migrations" section of the playbook now indexes both case studies so reviewers can see the playbook validated against two concrete files. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migrations/idaptik-user-settings.adoc | 326 ++++++++++++++++++ docs/guides/migration-playbook.adoc | 9 +- 2 files changed, 333 insertions(+), 2 deletions(-) create mode 100644 docs/guides/lessons/migrations/idaptik-user-settings.adoc diff --git a/docs/guides/lessons/migrations/idaptik-user-settings.adoc b/docs/guides/lessons/migrations/idaptik-user-settings.adoc new file mode 100644 index 0000000..c390c47 --- /dev/null +++ b/docs/guides/lessons/migrations/idaptik-user-settings.adoc @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += Migration Lesson: idaptik `UserSettings.res` → `UserSettings.affine` (design-only) +:revdate: 2026-05-02 +:icons: font +:source-highlighter: rouge + +[NOTE] +==== +**Status: design walk-through, not yet compiled.** Unlike +xref:idaptik-hitbox.adoc[idaptik-hitbox], this lesson does not produce a +compilable `.affine` file against AffineScript v0.1.0. `UserSettings.res` +depends on five external modules (`Storage`, `Audio`, `DesktopIntegration`, +`GetEngine`, `Option.getOr`) and uses `Float` for volume values — neither the +dependency surface nor the Float-arithmetic typechecker gap is resolved yet. + +The lesson has value anyway: it stress-tests the +xref:../../migration-playbook.adoc[migration playbook] against a realistic, +dependency-heavy file rather than a pure leaf, and surfaces three small +playbook gaps that the hitbox lesson did not. +==== + +== Why this file + +`src/app/utils/UserSettings.res` (127 lines) is the persistence layer for +user-modifiable settings: character stance, particles, difficulty, three +volume levels, system tray. It is interesting because it does three jobs in +one place — read from Storage, push values into the runtime +(Audio/DesktopIntegration), supply defaults — with implicit IO and several +stringly-typed values. Every pattern in the +xref:../../migration-playbook.adoc#source-pattern-index[source-pattern index] +shows up at least once. + +Three of those patterns are walked through here. The rest are repetitions +of the same shape. + +== Pattern 1 — Sum-type round-trip through a string-keyed Storage + +=== Original (ReScript) + +[source,rescript] +---- +type stance = Jessica | Q +let keyStance = "character-stance" + +let getCharacterStance = (): stance => + switch Storage.getString(keyStance) { + | Some("Q") => Q + | _ => Jessica // accepts None, garbage, AND Some("Jessica") + } + +let setCharacterStance = (s: stance): unit => { + let value = switch s { | Jessica => "Jessica" | Q => "Q" } + Storage.setString(keyStance, value) +} +---- + +The asymmetry is the bug. `setCharacterStance(Jessica)` writes `"Jessica"`, +but `getCharacterStance` only matches `Some("Q") => Q` and falls through +everything else (including `Some("Jessica")` *and* arbitrary garbage in +storage) to `Jessica`. The default-on-fallthrough is doing two jobs: +"absent" and "malformed." + +=== Faithful-but-monolithic AffineScript + +[source,affinescript] +---- +// don't do this +type Stance = Jessica | Q + +fn get_character_stance() -> Stance { + match Storage.get_string("character-stance") { + Some("Q") => Q + _ => Jessica + } +} +---- + +Same bug, AffineScript syntax. Storage IO is invisible; "absent" and +"malformed" still collapse to the same default. + +=== Re-decomposed + +[source,affinescript] +---- +type Stance = Jessica | Q + +// Codecs are exhaustive. "absent" and "malformed" are different values. +fn parse_stance(s: ref String) -> Option[Stance] { + match s { + "Q" => Some(Q) + "Jessica" => Some(Jessica) + _ => None + } +} + +fn render_stance(s: Stance) -> String { + match s { Jessica => "Jessica", Q => "Q" } +} + +// IO is in the row. Defaults live in one place. +fn get_character_stance() -> Stance / Storage { + Storage.get_string("character-stance") + .and_then(parse_stance) + .unwrap_or(Jessica) +} +---- + +What the re-decomposition buys: + +- Reading garbage from Storage is now *separable from* reading nothing. A + malformed value can be logged, surfaced to the user, or — if the policy + is still "fall back to default" — handled at the same `unwrap_or` site + that handles absence. The choice becomes explicit. +- `parse_stance` is exhaustive and round-trips with `render_stance`. Adding + a third stance variant later is a single-file change with two compile + errors to fix; in the original it is a silent runtime bug. +- Every caller of `get_character_stance` declares `Storage` in its effect + row. No hidden persistence at the call site. + +== Pattern 2 — Stringly-typed config + +=== Original (ReScript) + +[source,rescript] +---- +let getDifficulty = (): string => + Storage.getString(keyDifficulty)->Option.getOr("normal") + +let setDifficulty = (d: string): unit => Storage.setString(keyDifficulty, d) +---- + +Difficulty is a `string` at the public boundary. Every call site has to +remember the magic values `"easy" | "normal" | "hard"`, and a typo +(`"hardd"`) becomes a silent default at the next read. + +=== Re-decomposed + +[source,affinescript] +---- +type Difficulty = Easy | Normal | Hard + +fn parse_difficulty(s: ref String) -> Option[Difficulty] { + match s { + "easy" => Some(Easy) + "normal" => Some(Normal) + "hard" => Some(Hard) + _ => None + } +} + +fn render_difficulty(d: Difficulty) -> String { + match d { Easy => "easy", Normal => "normal", Hard => "hard" } +} + +fn get_difficulty() -> Difficulty / Storage { + Storage.get_string("game-difficulty") + .and_then(parse_difficulty) + .unwrap_or(Normal) +} +---- + +This is the +xref:../../migration-playbook.adoc#source-pattern-index["any / unknown" rule] +applied to a ReScript file: a `string` at the public boundary is the same +abdication of a thesis as TypeScript's `any`. Pin it. + +== Pattern 3 — Coupled write through a service-locator global + +=== Original (ReScript) + +[source,rescript] +---- +let setMasterVolume = (value: float): unit => + switch GetEngine.get() { + | Some(_) => + Audio.setMasterVolume(value) + Storage.setNumber(keyVolumeMaster, value) + | None => () + } +---- + +This single function does three things: looks up a process-wide singleton +(`GetEngine.get()`), updates the audio runtime, persists to storage. If the +engine isn't available the entire operation silently no-ops — including the +storage write that *could* have succeeded without the engine. The UI thinks +the volume was set; it wasn't. + +=== Re-decomposed + +[source,affinescript] +---- +// 1. Engine is no longer a service-locator. It's an effect. +// Callers that need the engine declare it. Tests install a mock. +effect Engine { + fn current() -> Option[ref EngineHandle]; +} + +// 2. Audio and Storage are also effects. Their use shows up everywhere. +effect Audio { fn set_master_volume(v: Float) -> (); } +effect Storage { fn set_number(key: String, value: Float) -> (); } + +// 3. The "no engine" case is a typed Result, not a silent (). +// Callers must decide what to do with NoEngine. +type NoEngine = NoEngine + +fn set_master_volume(v: Float) + -> Result[(), NoEngine] / Storage + Audio + Engine +{ + match Engine.current() { + Some(_) => { + Audio.set_master_volume(v); + Storage.set_number("volume-master", v); + Ok(()) + } + None => Err(NoEngine) + } +} +---- + +The biggest win is the last one: the silent no-op becomes a `Result` the +caller must inspect. The UI can show a "settings unavailable" banner +instead of pretending the change took effect. This is the +xref:../../migration-playbook.adoc#anti-patterns[same failure-mode-made-visible +pattern as the file-buffer "use after close"] — the destination type system +is forced to do the work the source language was leaving to runtime +discipline. + +== Constraints from v0.1.0 — why this lesson does not compile + +The hitbox lesson translates to a working `Hitbox.affine` because hitbox is a +single-file, integer-arithmetic, dependency-free leaf. UserSettings is none +of those things. Three v0.1.0 gaps stand between this design and a real +compile: + +. **Float arithmetic typechecker gap.** Per the + xref:idaptik-hitbox.adoc[hitbox lesson], `+`/`-`/`*`/`/`/`<`/`>` default + to `Int` in v0.1.0. Volumes are `Float`. Even reading and writing them is + fine; any clamping or scaling is not. Workaround for now: avoid Float + *arithmetic* in the migrated code, only Float *plumbing*. +. **No `Storage` / `Audio` / `Engine` modules in the AffineScript stdlib.** + These would have to ship first (in idaptik or upstream) before any + `UserSettings.affine` could resolve its imports. Effects-as-modules is a + larger design discussion — see + xref:../../frontier-programming-practices/Human_Programming_Guide.adoc[Frontier + Programming Practices] §Effects. +. **Effect-row syntax (`/ Storage + Audio + Engine`) and `Result[T, E]` + propagation (`?`) are documented in the frontier-guide but not all yet + in the v0.1.0 typechecker.** Specific operator availability needs a + v0.1.x audit before this design can be committed as code. + +The honest reading: **UserSettings is the right second case study to +*design*, but the wrong second case study to *ship*.** A more dependency-light +file (e.g. `combat/PlayerHP.res` — six mutable Float fields, no IO, no +service-locators) is the natural next compilable target. UserSettings ships +when the stdlib catches up. + +== Playbook verdict + +=== Held up cleanly + +- *"Stringly-typed → sum type"* (TS table, Discriminated unions / `any`) — + covered Difficulty and Stance. +- *"`unit` returns from impure code → `() / IO`"* — covered Storage and + Audio effects. +- *"`option` → `Option[T]` direct mapping"* — covered the `Option.getOr` + pattern. +- *"`let _ = ` discarded `Result`"* — surfaced by the system-tray code + (`let _ = DesktopIntegration.toggleSystemTray(...)`); becomes explicit + `Result` handling in the re-decomposed form. + +=== Gaps the playbook should address + +. **Service-locator globals.** `GetEngine.get(): Option` is a + common ReScript pattern and its re-decomposition (lift to effect) is + not named in the source-pattern index. The playbook covers `ref` and + `let mutable` but not "module-level getter for a singleton runtime." + *Proposed addition: one row in the ReScript table.* +. **Coupled-write composition.** When one user action both updates an + in-memory subsystem AND persists to Storage (the volume case), the + playbook does not say whether to express this as one function calling + two effects or as a wrapper effect. *Proposed addition: a short + subsection in + xref:../../migration-playbook.adoc#decision-criteria[Decision Criteria].* +. **Lessons-folder pointer in the playbook is wrong.** It points at + `docs/guides/lessons/` but that folder is the 10-lesson tutorial track; + the actual destination is `docs/guides/lessons/migrations/`. + *Proposed fix: one-line correction, alongside this lesson's commit.* + +Gap (3) is fixed in the same commit as this file. Gaps (1) and (2) are +playbook-text changes deferred to a v1.1 follow-up so the policy PR (#36) +can land focused. + +== What this lesson covers, generalised + +When translating a multi-pattern, dependency-heavy `.res` file: + +. **Surface the asymmetry first.** Every "default on fallthrough" hides + two cases: absent and malformed. Split them in the codec. +. **A `string` at a module's public boundary is the same abdication as + `any`.** Pin every string that has a fixed set of legal values. +. **A service-locator (`Module.get()` returning `Option`) is mutable + global state.** It deserves the same treatment as `let mutable` — lift + to an effect. The "is the runtime available?" branch becomes a typed + Result, not a silent no-op. +. **If the destination toolchain cannot yet compile the file, write the + lesson anyway.** The design walk-through pressures the playbook the + same way a working compile does. Mark the status honestly so future + readers know what to verify. + +== Reproducibility + +This lesson is design-only against AffineScript v0.1.0 — it does not produce +a compilable `.affine` file. The original ReScript: + +[source,bash] +---- +$ wc -l idaptik/src/app/utils/UserSettings.res +127 idaptik/src/app/utils/UserSettings.res +---- + +A compilable translation will be added at `idaptik/src/app/utils/UserSettings.affine` +once the stdlib provides `Storage`, `Audio`, and `Engine` modules and the +effect-row + `Result[T, E]` operator surface is confirmed in the v0.1.x +typechecker. When that happens, this lesson will gain a Reproducibility +block matching xref:idaptik-hitbox.adoc[idaptik-hitbox]. diff --git a/docs/guides/migration-playbook.adoc b/docs/guides/migration-playbook.adoc index 0a9d73a..68e2cbb 100644 --- a/docs/guides/migration-playbook.adoc +++ b/docs/guides/migration-playbook.adoc @@ -247,9 +247,14 @@ The two versions are roughly the same number of lines. They are different progra == Lessons from Real Migrations -Case studies from in-flight migrations land in link:lessons/[`docs/guides/lessons/`]. +Case studies from in-flight migrations land in link:lessons/migrations/[`docs/guides/lessons/migrations/`]. (The sibling `lessons/` folder is the 10-lesson tutorial track for new AffineScript users — different audience.) -If you complete a non-trivial `.res → .affine` translation and the re-decomposition reveals something future translators would benefit from, write it up there. The format is loose: original snippet, faithful port, re-decomposed port, and what the second one buys you. +Existing entries: + +* link:lessons/migrations/idaptik-hitbox.adoc[idaptik `Hitbox.res` → `Hitbox.affine`] — the first compilable translation; pure-leaf collision primitives. Surfaces the v0.1.0 Float-arithmetic typechecker gap. +* link:lessons/migrations/idaptik-user-settings.adoc[idaptik `UserSettings.res` (design-only)] — multi-pattern, dependency-heavy persistence layer. Stress-tests the playbook against five external module dependencies; surfaces three small playbook gaps. + +If you complete a non-trivial `.res → .affine` translation and the re-decomposition reveals something future translators would benefit from, write it up there. The format is loose: original snippet, faithful port, re-decomposed port, and what the second one buys you. Mark the lesson "design-only" if the destination toolchain cannot yet compile it — the design walk-through still pressures the playbook. == See Also