Skip to content

Commit 09e92de

Browse files
fix(res-to-affine): report the effective engine and fail on degraded output (#730)
Fixes #729. When the tree-sitter walker cannot start, the tool falls back to the Phase-1 regex scanner, warns on stderr — and then **reports the requested engine and exits 0**: ``` res-to-affine: 0 findings, 0 translated [walker] → Model.affine $ echo $? 0 ``` It says `[walker]`. **The scanner produced that file.** Nothing in the exit code or the summary distinguishes it from a real port, so a sweep over hundreds of files logs success for every one. ## The consequence, measured `metadatastician/stapeln` migrated its entire frontend this way: ``` 47 .affine files, 20,995 lines 19,203 (91.5%) retained ReScript inside /* ORIGINAL RESCRIPT */ blocks 1,792 ( 8.5%) real AffineScript 0 function declarations, across ALL 47 files ``` Every file reported success. Every file contains **zero functions**. That repo isn't 8.5% migrated by intent — it's 8.5% migrated **by accident**. The campaign covers ~3,996 files across ~80 repos. ## Why it's so easy to hit - **`tools/vendor/` is gitignored by design** (`.gitignore:92`), so the grammar is absent on every clean clone. - **The default grammar path resolves relative to the current directory**, so running the tool from the repo you're migrating — the natural thing to do — misses a grammar that *is* installed. That second one caught me: the grammar was installed and it still fell back, because I invoked it from `stapeln/frontend/src`. ## This change - Tracks whether the walker was asked for but couldn't run. - **Reports the effective engine** — `[scanner (DEGRADED)]` rather than `[walker]`. The summary line no longer states something untrue. - **Exits 3** on degraded output, with a message naming both causes above. - Adds `--allow-scanner-fallback` for callers who genuinely want a declarations-only skeleton. - Also marks `--engine=scanner` + `--translate`/`--partial` as degraded — it already warned no translation would be emitted, but still exited 0, and an unusable output is unusable whatever the intent. **Deliberately not changed:** the default grammar path stays CWD-relative. Making it binary- or repo-root-relative is the better fix, but it changes behaviour for existing callers, so it belongs in its own change. The new error message names the trap in the meantime. ## Verified — all four paths, same input (`stapeln/Model.res`) | Invocation | Exit | Label | Functions | |---|---|---|---| | degraded | **3** | `[scanner (DEGRADED)]` | 0 | | degraded + `--allow-scanner-fallback` | **0** | — | 0 | | healthy (walker, grammar present) | **0** | `[walker]` | **8** | The 8-vs-0 is the whole point: identical invocation, identical input, and the only difference is whether the walker could load. Built with dune 3.17.2 / OCaml 5.3.0. Note `dune build` alone fails on `js/playground.bc.js` for want of `js_of_ocaml`; `dune build tools/res-to-affine/main.exe` is clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 838f804 commit 09e92de

1 file changed

Lines changed: 55 additions & 14 deletions

File tree

tools/res-to-affine/main.ml

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,25 @@ let engine_label = function
3535
| Scanner_engine -> "scanner"
3636
| Walker_engine -> "walker"
3737

38-
let run engine grammar_dir do_translate do_partial input output_opt =
38+
let run engine grammar_dir do_translate do_partial allow_fallback input
39+
output_opt =
3940
if not (Sys.file_exists input) then begin
4041
Format.eprintf "res-to-affine: input not found: %s@." input;
4142
exit 2
4243
end;
44+
(* Records whether the walker was ASKED for but could not run. Without this
45+
the summary line reported the REQUESTED engine, so a fallback printed
46+
"[walker]" while the scanner had produced the output -- which is why
47+
whole-repo sweeps degraded silently and nobody noticed. *)
48+
let degraded = ref false in
4349
let source = read_file input in
4450
let findings =
4551
match engine with
4652
| Scanner_engine -> Scanner.scan source
4753
| Walker_engine ->
4854
(try Walker.scan ~grammar_dir ~path:input ~source with
4955
| Failure msg ->
56+
degraded := true;
5057
Format.eprintf "res-to-affine: %s@." msg;
5158
Format.eprintf
5259
"res-to-affine: falling back to scanner engine for %s@."
@@ -62,6 +69,10 @@ let run engine grammar_dir do_translate do_partial input output_opt =
6269
else
6370
match engine with
6471
| Scanner_engine ->
72+
(* Degraded even when the scanner was chosen deliberately: asking for
73+
--translate/--partial and getting no translation is a useless
74+
output whatever the intent, and a sweep must be able to see it. *)
75+
degraded := true;
6576
Format.eprintf
6677
"res-to-affine: --translate/--partial need the walker engine; \
6778
no translation emitted for %s@." input;
@@ -72,6 +83,7 @@ let run engine grammar_dir do_translate do_partial input output_opt =
7283
in
7384
(try f ~grammar_dir ~path:input ~source with
7485
| Failure msg ->
86+
degraded := true;
7587
Format.eprintf "res-to-affine: %s@." msg;
7688
Format.eprintf
7789
"res-to-affine: no translation emitted for %s@." input;
@@ -88,18 +100,38 @@ let run engine grammar_dir do_translate do_partial input output_opt =
88100
else
89101
Emitter.emit ~module_name ~source_path:input ~source ~findings
90102
in
91-
match output_opt with
92-
| None ->
93-
print_string out
94-
| Some path ->
95-
write_file path out;
96-
Format.printf
97-
"res-to-affine: %d finding%s, %d translated [%s] → %s@."
98-
(List.length findings)
99-
(if List.length findings = 1 then "" else "s")
100-
(List.length translated)
101-
(engine_label engine)
102-
path
103+
(match output_opt with
104+
| None ->
105+
print_string out
106+
| Some path ->
107+
write_file path out;
108+
(* Report the EFFECTIVE engine, not the requested one. *)
109+
Format.printf
110+
"res-to-affine: %d finding%s, %d translated [%s] → %s@."
111+
(List.length findings)
112+
(if List.length findings = 1 then "" else "s")
113+
(List.length translated)
114+
(if !degraded then "scanner (DEGRADED)" else engine_label engine)
115+
path);
116+
117+
(* Fail loudly rather than at exit 0. A sweep over hundreds of files cannot
118+
otherwise distinguish a real port from a function-free skeleton, and the
119+
stderr warning scrolls past. metadatastician/stapeln migrated all 47 of
120+
its frontend modules this way: every file reported success, every file
121+
contained zero functions. *)
122+
if !degraded && not allow_fallback then begin
123+
Format.eprintf
124+
"res-to-affine: DEGRADED OUTPUT for %s — the walker engine was \
125+
unavailable, so no functions were translated.@." input;
126+
Format.eprintf
127+
"res-to-affine: install the grammar (`just install-grammar`) or pass \
128+
`--grammar-dir`; note the default path is resolved relative to the \
129+
CURRENT DIRECTORY, so run this from the affinescript repo root.@.";
130+
Format.eprintf
131+
"res-to-affine: pass `--allow-scanner-fallback` if a \
132+
declarations-only skeleton really is what you want.@.";
133+
exit 3
134+
end
103135

104136
(* ---- cmdliner wiring ---- *)
105137

@@ -159,13 +191,22 @@ let partial_arg =
159191
in
160192
Cmdliner.Arg.(value & flag & info ["partial"] ~doc)
161193

194+
let allow_fallback_arg =
195+
let doc =
196+
"Exit 0 even when the walker engine was unavailable and the scanner \
197+
produced the output. Without this, a degraded run exits 3, because a \
198+
declarations-only skeleton with no functions is almost never what a \
199+
migration sweep wants and the stderr warning is easy to miss."
200+
in
201+
Cmdliner.Arg.(value & flag & info ["allow-scanner-fallback"] ~doc)
202+
162203
let cmd =
163204
let doc = "Emit an AffineScript skeleton from a ReScript source file." in
164205
let info = Cmdliner.Cmd.info "res-to-affine" ~version:"0.1.0" ~doc in
165206
let term =
166207
Cmdliner.Term.(
167208
const run $ engine_arg $ grammar_dir_arg $ translate_arg $ partial_arg
168-
$ input_arg $ output_arg)
209+
$ allow_fallback_arg $ input_arg $ output_arg)
169210
in
170211
Cmdliner.Cmd.v info term
171212

0 commit comments

Comments
 (0)