Skip to content

Commit 7644c85

Browse files
Merge pull request #36 from hyperpolymath/docs/migration-playbook
docs(guides): add Migration Playbook + frontier-guide v1.0 living-doc header
2 parents 2f43839 + 9d03dd5 commit 7644c85

3 files changed

Lines changed: 630 additions & 1 deletion

File tree

docs/guides/frontier-guide.adoc

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
// SPDX-License-Identifier: PMPL-1.0-or-later
22
= AffineScript Frontier Guide: The Unveiling
33
Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
4-
2026-04-11
4+
v1.0, 2026-04-11
55
:toc:
66
:icons: font
77
:source-highlighter: rouge
8+
:revnumber: 1.0
9+
:revdate: 2026-04-11
10+
11+
[NOTE]
12+
====
13+
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 <<revision-history>> appendix so the timeline is visible to future readers and AI agents.
14+
15+
For systematic translation patterns from ReScript, TypeScript, or another `-script` family language, read this guide first, then the link:migration-playbook.adoc[Migration Playbook].
16+
====
817

918
The Frontier Guide takes you from "I write JavaScript / Python" to
1019
"I understand why AffineScript's design choices produce better programs."
@@ -359,6 +368,8 @@ fn fetch_with_retry(
359368
== What to Read Next
360369

361370
- link:warmup/README.adoc[Warmup scripts] — hands-on exercises, 15 minutes each
371+
- link:migration-playbook.adoc[Migration Playbook] — systematic re-decomposition rules for porting ReScript / TypeScript / other `-script` codebases into AffineScript
372+
- link:frontier-programming-practices/Human_Programming_Guide.adoc[Frontier Programming Practices] — the wider design philosophy (v2.0, 2026-04-10)
362373
- link:../specs/SPEC.md[Language Specification] — the authoritative grammar and semantics
363374
- link:../specs/SETTLED-DECISIONS.adoc[Settled Decisions] — architectural choices and why they were made
364375
- 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
383394

384395
Additional faces (JS-face, Pseudocode-face, and others) are on the roadmap.
385396
See link:../specs/faces.md[faces.md] for the architecture.
397+
398+
[appendix#revision-history]
399+
== Revision History
400+
401+
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.
402+
403+
[cols="1,1,3"]
404+
|===
405+
| Revision | Date | Notes
406+
407+
| 1.0
408+
| 2026-04-11
409+
| Initial unveiling. The six X-Script problems and AffineScript's answers; chapters 1–6; complete-program example; faces.
410+
411+
|===
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
3+
= Migration Lesson: idaptik `UserSettings.res``UserSettings.affine` (design-only)
4+
:revdate: 2026-05-02
5+
:icons: font
6+
:source-highlighter: rouge
7+
8+
[NOTE]
9+
====
10+
**Status: design walk-through, not yet compiled.** Unlike
11+
xref:idaptik-hitbox.adoc[idaptik-hitbox], this lesson does not produce a
12+
compilable `.affine` file against AffineScript v0.1.0. `UserSettings.res`
13+
depends on five external modules (`Storage`, `Audio`, `DesktopIntegration`,
14+
`GetEngine`, `Option.getOr`) and uses `Float` for volume values — neither the
15+
dependency surface nor the Float-arithmetic typechecker gap is resolved yet.
16+
17+
The lesson has value anyway: it stress-tests the
18+
xref:../../migration-playbook.adoc[migration playbook] against a realistic,
19+
dependency-heavy file rather than a pure leaf, and surfaces three small
20+
playbook gaps that the hitbox lesson did not.
21+
====
22+
23+
== Why this file
24+
25+
`src/app/utils/UserSettings.res` (127 lines) is the persistence layer for
26+
user-modifiable settings: character stance, particles, difficulty, three
27+
volume levels, system tray. It is interesting because it does three jobs in
28+
one place — read from Storage, push values into the runtime
29+
(Audio/DesktopIntegration), supply defaults — with implicit IO and several
30+
stringly-typed values. Every pattern in the
31+
xref:../../migration-playbook.adoc#source-pattern-index[source-pattern index]
32+
shows up at least once.
33+
34+
Three of those patterns are walked through here. The rest are repetitions
35+
of the same shape.
36+
37+
== Pattern 1 — Sum-type round-trip through a string-keyed Storage
38+
39+
=== Original (ReScript)
40+
41+
[source,rescript]
42+
----
43+
type stance = Jessica | Q
44+
let keyStance = "character-stance"
45+
46+
let getCharacterStance = (): stance =>
47+
switch Storage.getString(keyStance) {
48+
| Some("Q") => Q
49+
| _ => Jessica // accepts None, garbage, AND Some("Jessica")
50+
}
51+
52+
let setCharacterStance = (s: stance): unit => {
53+
let value = switch s { | Jessica => "Jessica" | Q => "Q" }
54+
Storage.setString(keyStance, value)
55+
}
56+
----
57+
58+
The asymmetry is the bug. `setCharacterStance(Jessica)` writes `"Jessica"`,
59+
but `getCharacterStance` only matches `Some("Q") => Q` and falls through
60+
everything else (including `Some("Jessica")` *and* arbitrary garbage in
61+
storage) to `Jessica`. The default-on-fallthrough is doing two jobs:
62+
"absent" and "malformed."
63+
64+
=== Faithful-but-monolithic AffineScript
65+
66+
[source,affinescript]
67+
----
68+
// don't do this
69+
type Stance = Jessica | Q
70+
71+
fn get_character_stance() -> Stance {
72+
match Storage.get_string("character-stance") {
73+
Some("Q") => Q
74+
_ => Jessica
75+
}
76+
}
77+
----
78+
79+
Same bug, AffineScript syntax. Storage IO is invisible; "absent" and
80+
"malformed" still collapse to the same default.
81+
82+
=== Re-decomposed
83+
84+
[source,affinescript]
85+
----
86+
type Stance = Jessica | Q
87+
88+
// Codecs are exhaustive. "absent" and "malformed" are different values.
89+
fn parse_stance(s: ref String) -> Option[Stance] {
90+
match s {
91+
"Q" => Some(Q)
92+
"Jessica" => Some(Jessica)
93+
_ => None
94+
}
95+
}
96+
97+
fn render_stance(s: Stance) -> String {
98+
match s { Jessica => "Jessica", Q => "Q" }
99+
}
100+
101+
// IO is in the row. Defaults live in one place.
102+
fn get_character_stance() -> Stance / Storage {
103+
Storage.get_string("character-stance")
104+
.and_then(parse_stance)
105+
.unwrap_or(Jessica)
106+
}
107+
----
108+
109+
What the re-decomposition buys:
110+
111+
- Reading garbage from Storage is now *separable from* reading nothing. A
112+
malformed value can be logged, surfaced to the user, or — if the policy
113+
is still "fall back to default" — handled at the same `unwrap_or` site
114+
that handles absence. The choice becomes explicit.
115+
- `parse_stance` is exhaustive and round-trips with `render_stance`. Adding
116+
a third stance variant later is a single-file change with two compile
117+
errors to fix; in the original it is a silent runtime bug.
118+
- Every caller of `get_character_stance` declares `Storage` in its effect
119+
row. No hidden persistence at the call site.
120+
121+
== Pattern 2 — Stringly-typed config
122+
123+
=== Original (ReScript)
124+
125+
[source,rescript]
126+
----
127+
let getDifficulty = (): string =>
128+
Storage.getString(keyDifficulty)->Option.getOr("normal")
129+
130+
let setDifficulty = (d: string): unit => Storage.setString(keyDifficulty, d)
131+
----
132+
133+
Difficulty is a `string` at the public boundary. Every call site has to
134+
remember the magic values `"easy" | "normal" | "hard"`, and a typo
135+
(`"hardd"`) becomes a silent default at the next read.
136+
137+
=== Re-decomposed
138+
139+
[source,affinescript]
140+
----
141+
type Difficulty = Easy | Normal | Hard
142+
143+
fn parse_difficulty(s: ref String) -> Option[Difficulty] {
144+
match s {
145+
"easy" => Some(Easy)
146+
"normal" => Some(Normal)
147+
"hard" => Some(Hard)
148+
_ => None
149+
}
150+
}
151+
152+
fn render_difficulty(d: Difficulty) -> String {
153+
match d { Easy => "easy", Normal => "normal", Hard => "hard" }
154+
}
155+
156+
fn get_difficulty() -> Difficulty / Storage {
157+
Storage.get_string("game-difficulty")
158+
.and_then(parse_difficulty)
159+
.unwrap_or(Normal)
160+
}
161+
----
162+
163+
This is the
164+
xref:../../migration-playbook.adoc#source-pattern-index["any / unknown" rule]
165+
applied to a ReScript file: a `string` at the public boundary is the same
166+
abdication of a thesis as TypeScript's `any`. Pin it.
167+
168+
== Pattern 3 — Coupled write through a service-locator global
169+
170+
=== Original (ReScript)
171+
172+
[source,rescript]
173+
----
174+
let setMasterVolume = (value: float): unit =>
175+
switch GetEngine.get() {
176+
| Some(_) =>
177+
Audio.setMasterVolume(value)
178+
Storage.setNumber(keyVolumeMaster, value)
179+
| None => ()
180+
}
181+
----
182+
183+
This single function does three things: looks up a process-wide singleton
184+
(`GetEngine.get()`), updates the audio runtime, persists to storage. If the
185+
engine isn't available the entire operation silently no-ops — including the
186+
storage write that *could* have succeeded without the engine. The UI thinks
187+
the volume was set; it wasn't.
188+
189+
=== Re-decomposed
190+
191+
[source,affinescript]
192+
----
193+
// 1. Engine is no longer a service-locator. It's an effect.
194+
// Callers that need the engine declare it. Tests install a mock.
195+
effect Engine {
196+
fn current() -> Option[ref EngineHandle];
197+
}
198+
199+
// 2. Audio and Storage are also effects. Their use shows up everywhere.
200+
effect Audio { fn set_master_volume(v: Float) -> (); }
201+
effect Storage { fn set_number(key: String, value: Float) -> (); }
202+
203+
// 3. The "no engine" case is a typed Result, not a silent ().
204+
// Callers must decide what to do with NoEngine.
205+
type NoEngine = NoEngine
206+
207+
fn set_master_volume(v: Float)
208+
-> Result[(), NoEngine] / Storage + Audio + Engine
209+
{
210+
match Engine.current() {
211+
Some(_) => {
212+
Audio.set_master_volume(v);
213+
Storage.set_number("volume-master", v);
214+
Ok(())
215+
}
216+
None => Err(NoEngine)
217+
}
218+
}
219+
----
220+
221+
The biggest win is the last one: the silent no-op becomes a `Result` the
222+
caller must inspect. The UI can show a "settings unavailable" banner
223+
instead of pretending the change took effect. This is the
224+
xref:../../migration-playbook.adoc#anti-patterns[same failure-mode-made-visible
225+
pattern as the file-buffer "use after close"] — the destination type system
226+
is forced to do the work the source language was leaving to runtime
227+
discipline.
228+
229+
== Constraints from v0.1.0 — why this lesson does not compile
230+
231+
The hitbox lesson translates to a working `Hitbox.affine` because hitbox is a
232+
single-file, integer-arithmetic, dependency-free leaf. UserSettings is none
233+
of those things. Three v0.1.0 gaps stand between this design and a real
234+
compile:
235+
236+
. **Float arithmetic typechecker gap.** Per the
237+
xref:idaptik-hitbox.adoc[hitbox lesson], `+`/`-`/`*`/`/`/`<`/`>` default
238+
to `Int` in v0.1.0. Volumes are `Float`. Even reading and writing them is
239+
fine; any clamping or scaling is not. Workaround for now: avoid Float
240+
*arithmetic* in the migrated code, only Float *plumbing*.
241+
. **No `Storage` / `Audio` / `Engine` modules in the AffineScript stdlib.**
242+
These would have to ship first (in idaptik or upstream) before any
243+
`UserSettings.affine` could resolve its imports. Effects-as-modules is a
244+
larger design discussion — see
245+
xref:../../frontier-programming-practices/Human_Programming_Guide.adoc[Frontier
246+
Programming Practices] §Effects.
247+
. **Effect-row syntax (`/ Storage + Audio + Engine`) and `Result[T, E]`
248+
propagation (`?`) are documented in the frontier-guide but not all yet
249+
in the v0.1.0 typechecker.** Specific operator availability needs a
250+
v0.1.x audit before this design can be committed as code.
251+
252+
The honest reading: **UserSettings is the right second case study to
253+
*design*, but the wrong second case study to *ship*.** A more dependency-light
254+
file (e.g. `combat/PlayerHP.res` — six mutable Float fields, no IO, no
255+
service-locators) is the natural next compilable target. UserSettings ships
256+
when the stdlib catches up.
257+
258+
== Playbook verdict
259+
260+
=== Held up cleanly
261+
262+
- *"Stringly-typed → sum type"* (TS table, Discriminated unions / `any`) —
263+
covered Difficulty and Stance.
264+
- *"`unit` returns from impure code → `() / IO`"* — covered Storage and
265+
Audio effects.
266+
- *"`option<T>` → `Option[T]` direct mapping"* — covered the `Option.getOr`
267+
pattern.
268+
- *"`let _ = ` discarded `Result`"* — surfaced by the system-tray code
269+
(`let _ = DesktopIntegration.toggleSystemTray(...)`); becomes explicit
270+
`Result` handling in the re-decomposed form.
271+
272+
=== Gaps the playbook should address
273+
274+
. **Service-locator globals.** `GetEngine.get(): Option<engine>` is a
275+
common ReScript pattern and its re-decomposition (lift to effect) is
276+
not named in the source-pattern index. The playbook covers `ref` and
277+
`let mutable` but not "module-level getter for a singleton runtime."
278+
*Proposed addition: one row in the ReScript table.*
279+
. **Coupled-write composition.** When one user action both updates an
280+
in-memory subsystem AND persists to Storage (the volume case), the
281+
playbook does not say whether to express this as one function calling
282+
two effects or as a wrapper effect. *Proposed addition: a short
283+
subsection in
284+
xref:../../migration-playbook.adoc#decision-criteria[Decision Criteria].*
285+
. **Lessons-folder pointer in the playbook is wrong.** It points at
286+
`docs/guides/lessons/` but that folder is the 10-lesson tutorial track;
287+
the actual destination is `docs/guides/lessons/migrations/`.
288+
*Proposed fix: one-line correction, alongside this lesson's commit.*
289+
290+
Gap (3) is fixed in the same commit as this file. Gaps (1) and (2) are
291+
playbook-text changes deferred to a v1.1 follow-up so the policy PR (#36)
292+
can land focused.
293+
294+
== What this lesson covers, generalised
295+
296+
When translating a multi-pattern, dependency-heavy `.res` file:
297+
298+
. **Surface the asymmetry first.** Every "default on fallthrough" hides
299+
two cases: absent and malformed. Split them in the codec.
300+
. **A `string` at a module's public boundary is the same abdication as
301+
`any`.** Pin every string that has a fixed set of legal values.
302+
. **A service-locator (`Module.get()` returning `Option<thing>`) is mutable
303+
global state.** It deserves the same treatment as `let mutable` — lift
304+
to an effect. The "is the runtime available?" branch becomes a typed
305+
Result, not a silent no-op.
306+
. **If the destination toolchain cannot yet compile the file, write the
307+
lesson anyway.** The design walk-through pressures the playbook the
308+
same way a working compile does. Mark the status honestly so future
309+
readers know what to verify.
310+
311+
== Reproducibility
312+
313+
This lesson is design-only against AffineScript v0.1.0 — it does not produce
314+
a compilable `.affine` file. The original ReScript:
315+
316+
[source,bash]
317+
----
318+
$ wc -l idaptik/src/app/utils/UserSettings.res
319+
127 idaptik/src/app/utils/UserSettings.res
320+
----
321+
322+
A compilable translation will be added at `idaptik/src/app/utils/UserSettings.affine`
323+
once the stdlib provides `Storage`, `Audio`, and `Engine` modules and the
324+
effect-row + `Result[T, E]` operator surface is confirmed in the v0.1.x
325+
typechecker. When that happens, this lesson will gain a Reproducibility
326+
block matching xref:idaptik-hitbox.adoc[idaptik-hitbox].

0 commit comments

Comments
 (0)