From 88cb691f7d069f2494f125bce919170c446eaaf8 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:27:02 +0100 Subject: [PATCH] fix(res-to-affine): report the EFFECTIVE engine and fail on degraded output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 about the exit code or the summary line 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. The repo is not 8.5% migrated by intent; it is 8.5% migrated by accident, and the campaign covers ~3,996 files across ~80 repos. WHY IT IS SO EASY TO HIT. `tools/vendor/` is gitignored by design (.gitignore:92) so the grammar is absent on every clean clone; and the default grammar path is resolved relative to the CURRENT DIRECTORY, so running the tool from the repo being migrated -- the natural thing to do -- misses a grammar that IS installed. That caught me: the grammar was installed and the tool still fell back, because I invoked it from stapeln/frontend/src. THIS CHANGE * Tracks whether the walker was asked for but could not run. * Reports the EFFECTIVE engine: `[scanner (DEGRADED)]` instead of `[walker]`. The summary line no longer states something untrue. * Exits 3 on degraded output, with a message naming the two causes above. * Adds `--allow-scanner-fallback` for callers that genuinely want a declarations-only skeleton. * Also marks `--engine=scanner` combined with `--translate`/`--partial` as degraded: it already warned that 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 changes behaviour for existing callers, so it belongs in its own change. The new error message names the trap explicitly in the meantime. VERIFIED, all four paths: degraded exit 3, [scanner (DEGRADED)], 0 fns degraded + --allow-... exit 0 healthy (walker, grammar ok) exit 0, [walker], 8 translated, 8 fns same input throughout (stapeln's Model.res) The 8-vs-0 function count is the whole point: identical invocation, identical input, and the only difference is whether the walker could load. Reported as #729. --- tools/res-to-affine/main.ml | 69 +++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/tools/res-to-affine/main.ml b/tools/res-to-affine/main.ml index 77d4a9a7..f424aa31 100644 --- a/tools/res-to-affine/main.ml +++ b/tools/res-to-affine/main.ml @@ -35,11 +35,17 @@ let engine_label = function | Scanner_engine -> "scanner" | Walker_engine -> "walker" -let run engine grammar_dir do_translate do_partial input output_opt = +let run engine grammar_dir do_translate do_partial allow_fallback input + output_opt = if not (Sys.file_exists input) then begin Format.eprintf "res-to-affine: input not found: %s@." input; exit 2 end; + (* Records whether the walker was ASKED for but could not run. Without this + the summary line reported the REQUESTED engine, so a fallback printed + "[walker]" while the scanner had produced the output -- which is why + whole-repo sweeps degraded silently and nobody noticed. *) + let degraded = ref false in let source = read_file input in let findings = match engine with @@ -47,6 +53,7 @@ let run engine grammar_dir do_translate do_partial input output_opt = | Walker_engine -> (try Walker.scan ~grammar_dir ~path:input ~source with | Failure msg -> + degraded := true; Format.eprintf "res-to-affine: %s@." msg; Format.eprintf "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 = else match engine with | Scanner_engine -> + (* Degraded even when the scanner was chosen deliberately: asking for + --translate/--partial and getting no translation is a useless + output whatever the intent, and a sweep must be able to see it. *) + degraded := true; Format.eprintf "res-to-affine: --translate/--partial need the walker engine; \ no translation emitted for %s@." input; @@ -72,6 +83,7 @@ let run engine grammar_dir do_translate do_partial input output_opt = in (try f ~grammar_dir ~path:input ~source with | Failure msg -> + degraded := true; Format.eprintf "res-to-affine: %s@." msg; Format.eprintf "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 = else Emitter.emit ~module_name ~source_path:input ~source ~findings in - match output_opt with - | None -> - print_string out - | Some path -> - write_file path out; - Format.printf - "res-to-affine: %d finding%s, %d translated [%s] → %s@." - (List.length findings) - (if List.length findings = 1 then "" else "s") - (List.length translated) - (engine_label engine) - path + (match output_opt with + | None -> + print_string out + | Some path -> + write_file path out; + (* Report the EFFECTIVE engine, not the requested one. *) + Format.printf + "res-to-affine: %d finding%s, %d translated [%s] → %s@." + (List.length findings) + (if List.length findings = 1 then "" else "s") + (List.length translated) + (if !degraded then "scanner (DEGRADED)" else engine_label engine) + path); + + (* Fail loudly rather than at exit 0. A sweep over hundreds of files cannot + otherwise distinguish a real port from a function-free skeleton, and the + stderr warning scrolls past. metadatastician/stapeln migrated all 47 of + its frontend modules this way: every file reported success, every file + contained zero functions. *) + if !degraded && not allow_fallback then begin + Format.eprintf + "res-to-affine: DEGRADED OUTPUT for %s — the walker engine was \ + unavailable, so no functions were translated.@." input; + Format.eprintf + "res-to-affine: install the grammar (`just install-grammar`) or pass \ + `--grammar-dir`; note the default path is resolved relative to the \ + CURRENT DIRECTORY, so run this from the affinescript repo root.@."; + Format.eprintf + "res-to-affine: pass `--allow-scanner-fallback` if a \ + declarations-only skeleton really is what you want.@."; + exit 3 + end (* ---- cmdliner wiring ---- *) @@ -159,13 +191,22 @@ let partial_arg = in Cmdliner.Arg.(value & flag & info ["partial"] ~doc) +let allow_fallback_arg = + let doc = + "Exit 0 even when the walker engine was unavailable and the scanner \ + produced the output. Without this, a degraded run exits 3, because a \ + declarations-only skeleton with no functions is almost never what a \ + migration sweep wants and the stderr warning is easy to miss." + in + Cmdliner.Arg.(value & flag & info ["allow-scanner-fallback"] ~doc) + let cmd = let doc = "Emit an AffineScript skeleton from a ReScript source file." in let info = Cmdliner.Cmd.info "res-to-affine" ~version:"0.1.0" ~doc in let term = Cmdliner.Term.( const run $ engine_arg $ grammar_dir_arg $ translate_arg $ partial_arg - $ input_arg $ output_arg) + $ allow_fallback_arg $ input_arg $ output_arg) in Cmdliner.Cmd.v info term