|
| 1 | +<!-- SPDX-License-Identifier: MPL-2.0 --> |
| 2 | +<!-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell --> |
| 3 | + |
| 4 | +# `res-to-affine` — ReScript-to-AffineScript migration assistant |
| 5 | + |
| 6 | +A small OCaml CLI that reads a `.res` file and emits a `.affine` skeleton |
| 7 | +with **migration markers** — comments that name each anti-pattern the |
| 8 | +scanner found, point at the source line, and propose the AffineScript |
| 9 | +answer the human migrator should consider before porting. |
| 10 | + |
| 11 | +Tracks: [`affinescript#57`](https://github.com/hyperpolymath/affinescript/issues/57) |
| 12 | +(parser + metaparser). |
| 13 | +Consumed by: [`hyperpolymath/gitbot-fleet#148`](https://github.com/hyperpolymath/gitbot-fleet/issues/148) |
| 14 | +and the broader `idaptik` migration. |
| 15 | + |
| 16 | +## Usage |
| 17 | + |
| 18 | +```sh |
| 19 | +# print skeleton to stdout |
| 20 | +dune exec tools/res-to-affine/main.exe -- path/to/Foo.res |
| 21 | + |
| 22 | +# or write to a file |
| 23 | +dune exec tools/res-to-affine/main.exe -- path/to/Foo.res -o Foo.affine |
| 24 | +``` |
| 25 | + |
| 26 | +The output is **not compilable**. It is a starting point for the human: |
| 27 | +a quoted copy of the original sits at the bottom; the top carries a |
| 28 | +migration-considerations block; the middle is a `module` stub with |
| 29 | +`TODO`s. The human picks the decomposition; the tool surfaces what |
| 30 | +needs re-decomposing. |
| 31 | + |
| 32 | +## What gets flagged (Phase 1) |
| 33 | + |
| 34 | +The six anti-patterns surfaced in the |
| 35 | +[idaptik Wave 3 pilot](https://github.com/hyperpolymath/idaptik/blob/main/migration/main/LESSONS.md), |
| 36 | +of which the line-based scanner reliably detects four: |
| 37 | + |
| 38 | +| Tag | Detection | AffineScript answer | |
| 39 | +|---|---|---| |
| 40 | +| `side-effect-import` | `let _ = Mod.foo` at top level | Explicit registration call | |
| 41 | +| `raw-js` | `%raw(...)` or `[%bs.raw ...]` | Typed extern (`ABI-FFI-README.md`) | |
| 42 | +| `untyped-exception` | `Promise.catch`, `Js.Exn`, `raise`, `try` | `Result[E, A]` / `Validation[E, A]` | |
| 43 | +| `mutable-global` | `:=` operator | Affine record threaded through | |
| 44 | + |
| 45 | +Deferred to Phase 2 (need real AST): |
| 46 | + |
| 47 | +- **inline lambda callback record** — N ≥ 3 `~handler: (...) =>` lambdas |
| 48 | + inside one record literal (collapse to a row-polymorphic record). |
| 49 | +- **oversized function** — function body > ~50 LOC (decompose). |
| 50 | + |
| 51 | +## Why a skeleton and not a transliteration |
| 52 | + |
| 53 | +The Frontier Programming Guides' standing rule is **re-decompose, not |
| 54 | +transliterate**. A line-for-line port preserves the source's anti-patterns |
| 55 | +into the target language and produces `.affine` files that are technically |
| 56 | +parseable but architecturally still ReScript. The migration assistant's |
| 57 | +job is to *make the re-decomposition tractable*, not to skip it. So: |
| 58 | + |
| 59 | +- The skeleton is **honest about being incomplete** — it does not |
| 60 | + compile, on purpose. |
| 61 | +- The original source is **quoted at the bottom** so the migrator |
| 62 | + doesn't tab between files while writing the port. |
| 63 | +- Each marker links a source line to the AffineScript pattern that |
| 64 | + replaces it, so the migrator's next action is clear. |
| 65 | + |
| 66 | +## Phase plan |
| 67 | + |
| 68 | +### Phase 1 — text-scan emitter (this PR) |
| 69 | + |
| 70 | +- OCaml binary builds with the repo's existing `dune` toolchain. |
| 71 | +- `Scanner` walks lines with `str` regexes; cheap and dependency-free. |
| 72 | +- `Emitter` writes the migration-considerations block, a `module` stub, |
| 73 | + and the quoted source. |
| 74 | +- Snapshot tests under `test/` ensure stable output. |
| 75 | + |
| 76 | +This phase is **deliberately small**. It is useful immediately — runs |
| 77 | +against any `.res` file, surfaces 4 of 6 anti-patterns, gives the |
| 78 | +migrator a starting document — and it gates the architectural commitment |
| 79 | +to tree-sitter in Phase 2 behind something that already pays its way. |
| 80 | + |
| 81 | +### Phase 2 — tree-sitter AST walker |
| 82 | + |
| 83 | +- Install the pinned grammar from |
| 84 | + `editors/tree-sitter-rescript/` (manifest-only vendoring of |
| 85 | + `rescript-lang/tree-sitter-rescript@990214a`). |
| 86 | +- Replace `Scanner` with a walker over the s-expression output of |
| 87 | + `tree-sitter parse --quiet`, parsed by the existing `sexplib0` |
| 88 | + dependency. |
| 89 | +- Adds the two deferred patterns (callback records, oversized |
| 90 | + functions) and unlocks **structural** translation of trivial forms |
| 91 | + (e.g. `option<X>` → `Option[X]`, `result<X, Y>` → `Result[Y, X]`, |
| 92 | + `switch x { | A => ... }` → `match x { A => ... }`). |
| 93 | +- The `Emitter` interface does not change: same skeleton shape, same |
| 94 | + marker schema, richer body. |
| 95 | + |
| 96 | +### Phase 3 — partial translation |
| 97 | + |
| 98 | +Once the AST walker exists, the emitter can do more than mark — it can |
| 99 | +**translate** the pure-structural parts (type aliases, sum decls, |
| 100 | +simple `let` bindings, switch-to-match) and leave only effect-laden, |
| 101 | +exception-bearing, or globally-mutating regions as TODO. The skeleton |
| 102 | +becomes a working port of ~60–80% of the input, with TODO islands |
| 103 | +where re-decomposition is genuinely required. |
| 104 | + |
| 105 | +Phase 3 is when the tool earns its keep on idaptik's 542 files. |
| 106 | + |
| 107 | +## Testing |
| 108 | + |
| 109 | +```sh |
| 110 | +dune test tools/res-to-affine/ |
| 111 | +``` |
| 112 | + |
| 113 | +To regenerate snapshots after an intentional emitter change: |
| 114 | + |
| 115 | +```sh |
| 116 | +cd tools/res-to-affine/test |
| 117 | +../../../_build/default/tools/res-to-affine/main.exe \ |
| 118 | + fixtures/sample.res > expected/sample.affine |
| 119 | +``` |
| 120 | + |
| 121 | +The fixture under `test/fixtures/sample.res` is synthetic and exercises |
| 122 | +every Phase-1 anti-pattern. Real `.res` files from the estate (e.g. |
| 123 | +`gitbot-fleet/bots/sustainabot/bot-integration/src/*.res`) can be run |
| 124 | +ad hoc through the CLI without changes to the test suite. |
| 125 | + |
| 126 | +## Non-goals |
| 127 | + |
| 128 | +- **Not a ReScript compiler.** The scanner does not parse ReScript; |
| 129 | + even Phase 2 only walks the tree-sitter CST, not the ReScript |
| 130 | + type-checker's AST. If a `.res` file is syntactically invalid the |
| 131 | + tool may still emit a (less useful) skeleton. |
| 132 | +- **Not a build-time dependency on ReScript.** The pinned grammar is a |
| 133 | + parser, not the ReScript compiler. The estate's language policy |
| 134 | + (CLAUDE.md) bans new ReScript code; this tool exists to **help retire |
| 135 | + the existing ReScript surface**, not to bring more in. |
| 136 | +- **Not for editor integration.** Editor tree-sitter bindings for |
| 137 | + AffineScript live at `editors/tree-sitter-affinescript/`; this tool's |
| 138 | + vendored grammar is for the migration pipeline only. |
0 commit comments