Skip to content

Commit 5f51c4c

Browse files
committed
fix(sustainabot): reconcile hand-port against AffineScript grammar + stdlib
Static-review pass on the 2026-05-24 hand-port (per issue #148 policy override). Reconciles the .affine files against hyperpolymath/affinescript/lib/parser.mly and the canonical stdlib: - `open X` -> `use X::*;` (no `open` keyword in the lexer) - Each file now opens with a `module Name;` declaration - `Dict[K,V]` field types -> `[(K, V)]` (stdlib/dict.affine shape) - `Json.foo` -> `json::foo`, `Dict.foo` -> `dict::foo`, etc. (module paths use `::`; `.` is field access only) - Top-level `let x: T = ...` -> `const x: T = ...;` - Top-level `let _ = main()` removed (no top-level imperative slot) - `|>` pipe rewritten as nested calls / let chains (no pipe operator in the grammar) - tea/ServerTea.affine split into tea/Cmd.affine, tea/Sub.affine, tea/Runtime.affine; the inline `module Name { ... }` form is not in the parser - ServerTea's `let rec ... and ...` mutual-recursion dispatch loop collapsed to a TODO stub (no `let rec ... and ...` form); the mechanical migrator must rebuild against the final effect-row shape Drops the Json/Dict placeholder caveats from MIGRATION-NOTES: both stdlib/json.affine and stdlib/dict.affine have landed and are the canonical surfaces the rewrite now points at. Adds MISSING-EXTERNS.md listing every stdlib surface the hand-port calls that is not yet bound in canonical stdlib (Bytes, Base64, Time, Crypto extended, Env, Console, full Http::Request/Response, Array.*, Exn::message). The mechanical migrator (affinescript#57 Phase 3) must re-point each against whatever the canonical binding lands with. Prunes 24 stale .res entries from .hypatia-baseline.json: every bots/sustainabot/bot-integration/{src,bindings,lib/ocaml}/*.res path the migration removed. 35 entries remain (gsbot Python + a few others). Compiler check not run: the remote environment has no OCaml/dune/ affinescript toolchain. Edits verified against lib/parser.mly by hand only.
1 parent a117d4c commit 5f51c4c

17 files changed

Lines changed: 1153 additions & 1323 deletions

.hypatia-baseline.json

Lines changed: 0 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -208,149 +208,5 @@
208208
"rule_module": "cicd_rules",
209209
"type": "banned_language_file",
210210
"file": "bots/gsbot/tests/unit/test_services.py"
211-
},
212-
{
213-
"severity": "high",
214-
"rule_module": "code_safety",
215-
"type": "obj_magic",
216-
"file": "bots/sustainabot/bot-integration/lib/ocaml/Analysis.res"
217-
},
218-
{
219-
"severity": "high",
220-
"rule_module": "code_safety",
221-
"type": "obj_magic",
222-
"file": "bots/sustainabot/bot-integration/src/Analysis.res"
223-
},
224-
{
225-
"severity": "high",
226-
"rule_module": "migration_rules",
227-
"type": "deprecated_api",
228-
"file": "bots/sustainabot/bot-integration/bindings/Deno.res"
229-
},
230-
{
231-
"severity": "high",
232-
"rule_module": "migration_rules",
233-
"type": "deprecated_api",
234-
"file": "bots/sustainabot/bot-integration/lib/ocaml/Analysis.res"
235-
},
236-
{
237-
"severity": "high",
238-
"rule_module": "migration_rules",
239-
"type": "deprecated_api",
240-
"file": "bots/sustainabot/bot-integration/lib/ocaml/Deno.res"
241-
},
242-
{
243-
"severity": "high",
244-
"rule_module": "migration_rules",
245-
"type": "deprecated_api",
246-
"file": "bots/sustainabot/bot-integration/lib/ocaml/GitHubAPI.res"
247-
},
248-
{
249-
"severity": "high",
250-
"rule_module": "migration_rules",
251-
"type": "deprecated_api",
252-
"file": "bots/sustainabot/bot-integration/lib/ocaml/GitHubApp.res"
253-
},
254-
{
255-
"severity": "high",
256-
"rule_module": "migration_rules",
257-
"type": "deprecated_api",
258-
"file": "bots/sustainabot/bot-integration/lib/ocaml/Main.res"
259-
},
260-
{
261-
"severity": "high",
262-
"rule_module": "migration_rules",
263-
"type": "deprecated_api",
264-
"file": "bots/sustainabot/bot-integration/lib/ocaml/Oikos.res"
265-
},
266-
{
267-
"severity": "high",
268-
"rule_module": "migration_rules",
269-
"type": "deprecated_api",
270-
"file": "bots/sustainabot/bot-integration/lib/ocaml/Report.res"
271-
},
272-
{
273-
"severity": "high",
274-
"rule_module": "migration_rules",
275-
"type": "deprecated_api",
276-
"file": "bots/sustainabot/bot-integration/lib/ocaml/Router.res"
277-
},
278-
{
279-
"severity": "high",
280-
"rule_module": "migration_rules",
281-
"type": "deprecated_api",
282-
"file": "bots/sustainabot/bot-integration/lib/ocaml/ServerTea.res"
283-
},
284-
{
285-
"severity": "high",
286-
"rule_module": "migration_rules",
287-
"type": "deprecated_api",
288-
"file": "bots/sustainabot/bot-integration/lib/ocaml/Types.res"
289-
},
290-
{
291-
"severity": "high",
292-
"rule_module": "migration_rules",
293-
"type": "deprecated_api",
294-
"file": "bots/sustainabot/bot-integration/lib/ocaml/Webhook.res"
295-
},
296-
{
297-
"severity": "high",
298-
"rule_module": "migration_rules",
299-
"type": "deprecated_api",
300-
"file": "bots/sustainabot/bot-integration/src/Analysis.res"
301-
},
302-
{
303-
"severity": "high",
304-
"rule_module": "migration_rules",
305-
"type": "deprecated_api",
306-
"file": "bots/sustainabot/bot-integration/src/GitHubAPI.res"
307-
},
308-
{
309-
"severity": "high",
310-
"rule_module": "migration_rules",
311-
"type": "deprecated_api",
312-
"file": "bots/sustainabot/bot-integration/src/GitHubApp.res"
313-
},
314-
{
315-
"severity": "high",
316-
"rule_module": "migration_rules",
317-
"type": "deprecated_api",
318-
"file": "bots/sustainabot/bot-integration/src/Main.res"
319-
},
320-
{
321-
"severity": "high",
322-
"rule_module": "migration_rules",
323-
"type": "deprecated_api",
324-
"file": "bots/sustainabot/bot-integration/src/Oikos.res"
325-
},
326-
{
327-
"severity": "high",
328-
"rule_module": "migration_rules",
329-
"type": "deprecated_api",
330-
"file": "bots/sustainabot/bot-integration/src/Report.res"
331-
},
332-
{
333-
"severity": "high",
334-
"rule_module": "migration_rules",
335-
"type": "deprecated_api",
336-
"file": "bots/sustainabot/bot-integration/src/Router.res"
337-
},
338-
{
339-
"severity": "high",
340-
"rule_module": "migration_rules",
341-
"type": "deprecated_api",
342-
"file": "bots/sustainabot/bot-integration/src/Types.res"
343-
},
344-
{
345-
"severity": "high",
346-
"rule_module": "migration_rules",
347-
"type": "deprecated_api",
348-
"file": "bots/sustainabot/bot-integration/src/Webhook.res"
349-
},
350-
{
351-
"severity": "high",
352-
"rule_module": "migration_rules",
353-
"type": "deprecated_api",
354-
"file": "bots/sustainabot/bot-integration/src/tea/ServerTea.res"
355211
}
356212
]

bots/sustainabot/bot-integration/MIGRATION-NOTES.md

Lines changed: 56 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ This subtree was migrated from ReScript to AffineScript on 2026-05-24 by a
55
**hand-port under explicit policy override** of issue #148's
66
"do-not-hand-port-ahead-of-the-compiler" rule.
77

8+
A follow-up static-review pass on 2026-05-25 reconciled the hand-port
9+
against the canonical AffineScript grammar (`hyperpolymath/affinescript`
10+
`lib/parser.mly`) and stdlib. See **"Static review changes (2026-05-25)"**
11+
below.
12+
813
## Scope of migration
914

1015
- All `.res` / `.res.js` under `src/`, `src/tea/`, `bindings/`, and
@@ -16,42 +21,61 @@ This subtree was migrated from ReScript to AffineScript on 2026-05-24 by a
1621
- The 11 in-scope `.res` files were re-expressed as `.affine`:
1722
`Types`, `Config`, `Webhook`, `Analysis`, `GitHubAPI`, `GitHubApp`,
1823
`Report`, `Router`, `Oikos`, `Main`, and `tea/ServerTea`.
24+
- After the static-review pass, `tea/ServerTea.affine` was split into
25+
three siblings: `tea/Cmd.affine`, `tea/Sub.affine`, `tea/Runtime.affine`
26+
(the parser has no inline `module Name { ... }` form, so the original
27+
three-module file was structurally invalid).
28+
29+
## Static review changes (2026-05-25)
30+
31+
The hand-port was originally written against the README spec only,
32+
without access to the parser. Reconciling it against
33+
`lib/parser.mly` + the actual stdlib produced these edits:
34+
35+
| Change | Reason |
36+
|---|---|
37+
| `open Types``use Types::*;` | The lexer has no `open` keyword. Module-level imports use `use … ::*;` or `use … ::{Foo, Bar};`. |
38+
| Each file now starts with `module <Name>;` | The parser requires a single `module Name;` decl at the head of every file. |
39+
| `Dict[K, V]` field types → `[(K, V)]` | `stdlib/dict.affine` represents a dict as an assoc list of pairs (no `Dict` type symbol exists). |
40+
| `Json` references kept (no `<…>` args) | `stdlib/json.affine` exports `pub type Json = JNull \| JBool(Bool) \| …` — Json is unparameterised. |
41+
| `Json.foo(…)``json::foo(…)` | Module path uses `::`, and the canonical module is lowercase `json`. Similarly `Dict.foo``dict::foo`, `Option.foo``option::foo`. |
42+
| `String.foo(…)``String::foo(…)` etc. | Capital-cased modules (Http, Crypto, Bytes, Env, Console, Time, Base64, String, Int, Float, Exn, Array, Config) still need `::` for module access; `.` is field access only. |
43+
| Top-level `let x: T = …``const x: T = …;` | `let`-bindings only appear inside block/fn bodies. Module-level bindings use `const`. |
44+
| Top-level `let _ = main()` removed | There is no top-level imperative slot; the program entry is the `pub fn main` itself. |
45+
| `let rec … and …` (ServerTea Runtime) → individual `fn` decls with state passed explicitly, stubbed | The parser has no mutually-recursive `let rec … and …` form. The dispatch loop reduces to a TODO stub awaiting the mechanical migrator. |
46+
| Inline `module Cmd { … }` → separate `tea/Cmd.affine` (plus `Sub`/`Runtime`) | The parser only accepts file-level `module Name;` — inline module bodies are not in the grammar. |
47+
| `\|>` pipe operator → nested calls / let-binding chains | The lexer has no pipe operator. Each pipeline was rewritten as nested function calls or stepwise `let` bindings. |
48+
| Variant decls with leading `\|` allowed (kept consistent) | Both `type X = A \| B` and `type X = \| A \| B` parse; we kept the no-leading-pipe form. |
49+
50+
The Json/Dict placeholder caveats from the original notes are gone:
51+
`stdlib/json.affine` (the `Json` ADT, encoders/decoders, `stringify`) and
52+
`stdlib/dict.affine` (the `[(String, V)]` assoc-list dict) have both
53+
landed and are the canonical surfaces the rewritten files now point at.
54+
55+
**Compiler check not run.** The remote environment has no OCaml /
56+
`dune` / `affinescript` toolchain available, so `affinescript check
57+
src/` could not be executed. The edits above were verified against
58+
`lib/parser.mly` by hand only.
59+
60+
## Known unbound surfaces
1961

20-
## Known caveats (must be verified by the real mechanical migrator)
21-
22-
The hand-port was done without an AffineScript compiler available to the
23-
porter, against the canonical README spec only. Several pieces are
24-
educated guesses that the upstream Phase-3 migration assistant
25-
(affinescript#57 / PR #314) is expected to redo:
26-
27-
1. **`Json` and `Dict` are placeholder names** in many type signatures.
28-
The portable `Json` primitive is tracked by affinescript#161 (OPEN)
29-
and `Dict`/`Map` by #162 (OPEN). Once those land, the placeholders
30-
need to be re-pointed at the canonical stdlib names.
31-
2. **Effect rows** use a conservative `-{IO + Http + Crypto + Exn[E]}->`
32-
shape. These need narrowing against the actual effect declarations
33-
in the stdlib.
34-
3. **TEA runtime (`tea/ServerTea.affine`)** uses `mut` cells and
35-
mutually-recursive closures. The borrow checker may force this into
36-
a single-owner / handler-state shape — exact form to be settled by
37-
the actual checker.
38-
4. **JWT crypto chain (`GitHubApp.affine`)** assumes Web Crypto-derived
39-
stdlib bindings (`Crypto.import_key_pkcs8`,
40-
`Crypto.sign_with_algorithm`). Real binding names will land with
41-
`#103` (Async-extern ABI — closed 2026-05-18) once the canonical
42-
stdlib surface is published.
43-
5. **Bindings deleted** (`bindings/Deno.res`, `bindings/Fetch.res`) —
44-
their callers now reference target-agnostic stdlib names
45-
(`Http.fetch`, `Env.get`, `Console.log`, etc.). The compiler's
46-
target-binding layer is expected to lower these per backend.
62+
A list of stdlib surfaces these files call that are **not yet bound** in
63+
canonical AffineScript stdlib lives in `MISSING-EXTERNS.md`. The
64+
mechanical migrator (affinescript#57 Phase 3) must re-point those calls
65+
against whatever names the eventual bindings land with.
4766

4867
## Re-port checklist (when affinescript#57 Phase 3 lands)
4968

5069
- [ ] Run the mechanical migrator over the original `.res` (via
5170
`git show <pre-migration-sha>:path/to/file.res`) and diff its
5271
output against the hand-port.
53-
- [ ] Substitute placeholder `Json` / `Dict[String, V]` references with
54-
whatever canonical names #161 / #162 land with.
55-
- [ ] Re-narrow effect rows against the actual stdlib declarations.
56-
- [ ] Run `affinescript check src/` against every file and resolve any
57-
remaining errors that the hand-port couldn't anticipate.
72+
- [ ] Resolve every `TODO(mechanical-migrator):` annotation. The
73+
densest cluster is in `tea/Runtime.affine` (the dispatch loop
74+
stub) and `src/Main.affine` / `src/Router.affine` (the HTTP
75+
serve loop stubs).
76+
- [ ] Re-bind every name listed in `MISSING-EXTERNS.md`.
77+
- [ ] Re-narrow effect rows against the actual stdlib declarations
78+
(the hand-port uses a conservative `-{IO}->` everywhere).
79+
- [ ] Run `affinescript check src/` (in an environment that has the
80+
compiler) and resolve any remaining errors that the hand-port
81+
couldn't anticipate.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<!-- SPDX-License-Identifier: PMPL-1.0-or-later -->
2+
# Unbound stdlib surfaces
3+
4+
Generated by the static-review pass on 2026-05-25 (see
5+
`MIGRATION-NOTES.md`). Each entry lists a name called by the hand-port
6+
that does not currently appear in `hyperpolymath/affinescript`
7+
`stdlib/`. The mechanical migrator (affinescript#57 Phase 3) must
8+
re-point each call against whatever the canonical binding lands with,
9+
or supply an explicit `extern fn` declaration at the call site.
10+
11+
The names are kept here (rather than mass-inventing `extern fn`s in the
12+
.affine files) because guessing the wrong signature would silently
13+
mask the gap once the real binding lands.
14+
15+
## `Env`
16+
17+
| Call | Used in | Likely target |
18+
|---|---|---|
19+
| `Env::get(name) -> Option<String>` | Config, Oikos | Maps to the existing `getenv` builtin in `stdlib/io.affine`; awaits a thin `Env` module wrapper or a direct `use io::{getenv};` rewrite. |
20+
21+
## `String` (extensions)
22+
23+
`stdlib/string.affine` exists but does not cover these:
24+
25+
| Call | Used in |
26+
|---|---|
27+
| `String::to_lower(s)` | Config — canonical builtin is `to_lowercase(s)`, no module prefix |
28+
| `String::replace(s, needle, replacement)` | Webhook |
29+
| `String::replace_regex(s, pattern, replacement)` | GitHubApp, Main |
30+
| `String::replace_all(s, needle, replacement)` | GitHubApp, Report |
31+
| `String::split(s, sep)` | Router — canonical builtin is `split(s, sep)`, no module prefix |
32+
| `String::slice_from(s, n)` | Router |
33+
| `String::starts_with(s, prefix)` | Router |
34+
35+
## `Int` / `Float`
36+
37+
| Call | Used in | Likely target |
38+
|---|---|---|
39+
| `Int::to_string(n)` | every file | builtin `int_to_string(n)` (no module prefix) |
40+
| `Int::from_string(s)` | Config, Oikos | builtin `parse_int(s) -> Option<Int>` |
41+
| `Float::to_string(f)` | Report | builtin `float_to_string(f)` |
42+
| `Float::to_int(f)` | Report, GitHubAPI, Main, GitHubApp | needs a cast primitive |
43+
44+
## `Time`
45+
46+
No `Time` module in stdlib. The Crypto module exposes `time_ms() -> Int`
47+
which covers `Time::now_millis` if the lowering can pun Int↔Float.
48+
49+
| Call | Used in |
50+
|---|---|
51+
| `Time::now_millis() -> Float` | GitHubApp, Oikos |
52+
| `Time::iso_now() -> String` | Main |
53+
| `Time::parse_iso8601(s) -> Float` | GitHubApp (removed in static-review pass) |
54+
| `Time::set_interval(f, ms) -> Int` | (was in Runtime; now stubbed) |
55+
| `Time::clear_interval(id)` | (was in Runtime; now stubbed) |
56+
57+
## `Crypto` (extensions)
58+
59+
`stdlib/Crypto.affine` exports `random_string`, `random_f64`, `time_ms`
60+
only. The hand-port additionally calls:
61+
62+
| Call | Used in |
63+
|---|---|
64+
| `Crypto::import_key("raw", …)` | Webhook (HMAC) |
65+
| `Crypto::import_key_pkcs8(…)` | GitHubApp (RSA JWT) |
66+
| `Crypto::verify("HMAC", key, sig, data)` | Webhook |
67+
| `Crypto::sign_with_algorithm(alg, key, data)` | GitHubApp |
68+
69+
These map to Web Crypto under the Deno-ESM backend; the canonical
70+
binding will likely arrive with the `#103` Async-extern ABI rollout.
71+
72+
## `Bytes`
73+
74+
No `Bytes` module in stdlib.
75+
76+
| Call | Used in |
77+
|---|---|
78+
| `Bytes::from_utf8(s) -> [Int]` | Webhook, GitHubApp |
79+
| `Bytes::from_string(s) -> [Int]` | GitHubApp |
80+
81+
## `Base64`
82+
83+
No `Base64` module in stdlib.
84+
85+
| Call | Used in |
86+
|---|---|
87+
| `Base64::encode(bytes) -> String` | GitHubApp |
88+
| `Base64::decode(s) -> String` | GitHubApp |
89+
90+
## `Console`
91+
92+
| Call | Used in | Likely target |
93+
|---|---|---|
94+
| `Console::log(s)` | Oikos, Main | builtin `println(s)` (no module prefix) |
95+
| `Console::error(s)` | Oikos | builtin `eprintln(s)` |
96+
97+
## `Http` (extensions beyond `stdlib/Http.affine`)
98+
99+
`stdlib/Http.affine` provides `http_request`, `fetch`, `get`, `post`,
100+
`is_ok`, plus the `Request`/`Response` records. The hand-port
101+
additionally references:
102+
103+
| Call | Used in |
104+
|---|---|
105+
| `Http::Request` (as a type alias) | Router, Main |
106+
| `Http::Response` (as a type alias) | Router, Main |
107+
| `Http::serve(opts, handler)` | (was in Main; now stubbed) |
108+
| `Http::Server` (handle type) | (was in Runtime; now opaque-Int stubbed) |
109+
| `Http::Server::shutdown(s)` | (was in Runtime; now stubbed) |
110+
| `Http::not_implemented(req) -> Response` | Router (placeholder for the dispatch stub) |
111+
| `Http::Url::parse(url)`, `Http::Url::pathname(u)` | (was in Router; now stubbed) |
112+
| `Http::Request::method(req)`, `Http::Request::url(req)`, `Http::Request::headers(req)`, `Http::Request::text(req)` | (was in Router/Main; now stubbed) |
113+
| `Http::Response::make(body, opts) -> Response` | (was in Router/Main; now stubbed) |
114+
115+
## `Array`
116+
117+
`stdlib/prelude.affine` exports `map`, `filter`, `fold`, `contains`,
118+
`sum`, `product` as bare functions over `[T]`. The hand-port additionally
119+
calls:
120+
121+
| Call | Used in | Likely target |
122+
|---|---|---|
123+
| `Array::length(a)` | Report, Router | needs `len` builtin or a prelude `length` fn |
124+
| `Array::append(a, b)` | (was in Oikos; rewritten to `a ++ b`) | already covered by `++` |
125+
| `Array::map(a, f)` | (was in Report; rewritten to `map(a, f)`) | covered by prelude |
126+
| `Array::filter(a, p)` | (was in Oikos; rewritten to `filter(a, p)`) | covered by prelude |
127+
| `Array::slice(a, start, len)` | Report |
128+
| `Array::iter(a, f)` | (was in Router/Runtime; now stubbed) |
129+
| `Array::iter_indexed(a, f)` | (was in Router; now stubbed) |
130+
| `Array::fold_right(a, init, f)` | (was in Router; now stubbed) |
131+
| `Array::get(a, i)` | (was in Router; now stubbed) |
132+
| `Array::join(strs, sep)` | (was in Report; rewritten to `string::join(strs, sep)`) | covered by stdlib `string::join` |
133+
134+
## `Exn`
135+
136+
| Call | Used in | Likely target |
137+
|---|---|---|
138+
| `Exn::message(err)` | Analysis, GitHubAPI, GitHubApp | needs an `Exn` module or pattern-matched extraction |

0 commit comments

Comments
 (0)